website-build-skill 0.1.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/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +10 -0
- package/AGENTS.md +19 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +16 -0
- package/CODE_OF_CONDUCT.md +127 -0
- package/CONTRIBUTING.md +49 -0
- package/LICENSE +21 -0
- package/MAINTAINERS.md +12 -0
- package/README.md +148 -0
- package/RELEASING.md +133 -0
- package/REPO_SETTINGS.md +91 -0
- package/SECURITY.md +28 -0
- package/adapters/agents-md.md +26 -0
- package/adapters/antigravity.md +68 -0
- package/adapters/chatgpt-project.md +57 -0
- package/adapters/claude-code.md +25 -0
- package/adapters/claude-desktop.md +33 -0
- package/adapters/codex.md +21 -0
- package/adapters/custom-gpt.md +57 -0
- package/adapters/grok.md +100 -0
- package/adapters/hermes.md +49 -0
- package/adapters/team.yaml +42 -0
- package/adapters/zero-install.md +17 -0
- package/bin/website-build-skill.mjs +6 -0
- package/docs/COMPATIBILITY.md +34 -0
- package/docs/EVALUATION.md +109 -0
- package/docs/INSTALLER.md +73 -0
- package/docs/LIBRARY-CHECK.md +95 -0
- package/docs/METHOD.md +32 -0
- package/docs/PROVENANCE.md +36 -0
- package/docs/TIPS.md +18 -0
- package/docs/bundles/checklists.md +414 -0
- package/docs/bundles/method.md +1398 -0
- package/docs/bundles/playbooks.md +1566 -0
- package/docs/bundles/prompts.md +987 -0
- package/docs/bundles/team.md +1781 -0
- package/docs/evaluation/host-trial.md +95 -0
- package/docs/evaluation/receipt-template.json +18 -0
- package/docs/evidence/audit-baseline.json +61 -0
- package/docs/evidence/content-review.json +141 -0
- package/docs/evidence/floor.json +345 -0
- package/docs/evidence/library-check.json +164 -0
- package/docs/evidence/local-checks.json +156 -0
- package/llms.txt +74 -0
- package/package.json +68 -0
- package/skills/website-build-skill/SKILL.md +124 -0
- package/skills/website-build-skill/archive/graphics-design-original.md +70 -0
- package/skills/website-build-skill/checklists/accessibility.md +38 -0
- package/skills/website-build-skill/checklists/brand-and-mockups.md +43 -0
- package/skills/website-build-skill/checklists/build.md +39 -0
- package/skills/website-build-skill/checklists/performance-seo.md +40 -0
- package/skills/website-build-skill/checklists/research.md +91 -0
- package/skills/website-build-skill/checklists/security.md +38 -0
- package/skills/website-build-skill/checklists/ship.md +59 -0
- package/skills/website-build-skill/examples/audit-cases.md +66 -0
- package/skills/website-build-skill/examples/fictional-studio-brief.md +73 -0
- package/skills/website-build-skill/examples/fictional-studio-handoff.md +98 -0
- package/skills/website-build-skill/manifest.json +1474 -0
- package/skills/website-build-skill/playbooks/accessibility.md +91 -0
- package/skills/website-build-skill/playbooks/choose-stack.md +69 -0
- package/skills/website-build-skill/playbooks/data-and-templates.md +89 -0
- package/skills/website-build-skill/playbooks/deploy-and-operate.md +87 -0
- package/skills/website-build-skill/playbooks/embed-facades.md +59 -0
- package/skills/website-build-skill/playbooks/image-pipeline.md +73 -0
- package/skills/website-build-skill/playbooks/performance-budgets.md +76 -0
- package/skills/website-build-skill/playbooks/research-and-memory.md +398 -0
- package/skills/website-build-skill/playbooks/security-headers.md +132 -0
- package/skills/website-build-skill/playbooks/self-hosted-fonts.md +62 -0
- package/skills/website-build-skill/playbooks/structured-data-and-llms.md +84 -0
- package/skills/website-build-skill/prompts/00-start.md +94 -0
- package/skills/website-build-skill/prompts/01-website-deep-research.md +233 -0
- package/skills/website-build-skill/prompts/03-extract-brand-kit.md +46 -0
- package/skills/website-build-skill/prompts/04-three-mockups.md +44 -0
- package/skills/website-build-skill/prompts/05-stack-and-build.md +46 -0
- package/skills/website-build-skill/prompts/06-site-audit.md +39 -0
- package/skills/website-build-skill/prompts/07-accessibility-contrast.md +38 -0
- package/skills/website-build-skill/prompts/08-seo-aeo.md +72 -0
- package/skills/website-build-skill/prompts/09-security-headers.md +40 -0
- package/skills/website-build-skill/prompts/10-adversarial-pre-ship.md +44 -0
- package/skills/website-build-skill/prompts/11-ship-and-verify.md +44 -0
- package/skills/website-build-skill/prompts/12-web-design-team.md +141 -0
- package/skills/website-build-skill/references/stacks/framework.md +76 -0
- package/skills/website-build-skill/references/stacks/scripted-static.md +76 -0
- package/skills/website-build-skill/references/stacks/static-html.md +72 -0
- package/skills/website-build-skill/roles/builder.md +139 -0
- package/skills/website-build-skill/roles/coordinator.md +132 -0
- package/skills/website-build-skill/roles/designer.md +125 -0
- package/skills/website-build-skill/roles/graphics.md +126 -0
- package/skills/website-build-skill/roles/optimizer.md +129 -0
- package/skills/website-build-skill/roles/researcher.md +202 -0
- package/skills/website-build-skill/roles/reviewer.md +139 -0
- package/skills/website-build-skill/templates/audit-brief.md +63 -0
- package/skills/website-build-skill/templates/brand.md +53 -0
- package/skills/website-build-skill/templates/brief.md +51 -0
- package/skills/website-build-skill/templates/evidence.md +100 -0
- package/skills/website-build-skill/templates/handoff.yaml +23 -0
- package/skills/website-build-skill/templates/install-choice.txt +28 -0
- package/skills/website-build-skill/templates/site-operations.md +71 -0
- package/skills/website-build-skill/templates/status.md +47 -0
- package/skills/website-build-skill/templates/tokens.json +72 -0
- package/src/agents.mjs +7 -0
- package/src/bundle.mjs +106 -0
- package/src/catalog.mjs +12 -0
- package/src/choice.mjs +6 -0
- package/src/cli.mjs +96 -0
- package/src/install.mjs +344 -0
- package/src/library-check.mjs +106 -0
- package/src/validate.mjs +351 -0
package/src/validate.mjs
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { CORE, ARCHIVE, ARCHIVE_SHA, digest, files, read, manifest, generated, owned } from './bundle.mjs';
|
|
4
|
+
|
|
5
|
+
const roles = ['researcher', 'coordinator', 'designer', 'graphics', 'builder', 'optimizer', 'reviewer'];
|
|
6
|
+
const headings = ['IDENTITY', 'WHAT YOU OWN', 'WHAT YOU MUST NOT DO', 'WHAT YOU NEED TO KNOW BEFORE YOU START', 'WHAT YOU MUST LEARN', 'WHAT YOU MUST RESEARCH', 'YOUR TOOLS', 'YOUR INPUTS', 'YOUR OUTPUTS', 'YOUR GATE', 'YOUR HANDOFF', 'HOW YOU FAIL'];
|
|
7
|
+
const owner = ['au', 'ny', 'sillyme'].join('');
|
|
8
|
+
const personal = ['au', 'ny'].join('');
|
|
9
|
+
const product = ['Claude', 'Code'].join(' ');
|
|
10
|
+
const forbidden = [personal, product, ['Vau','lts'].join(''), ['A','UN-'].join(''), ['linear','.app'].join(''), ['/','Users','/'].join(''), ['@','gmail'].join(''), ['site','builder','skill'].join('-')];
|
|
11
|
+
export const privacyPattern = new RegExp(forbidden.map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'i');
|
|
12
|
+
|
|
13
|
+
export function privacyIssues(name, text, options = {}) {
|
|
14
|
+
const issues = [];
|
|
15
|
+
// A private identifier can reach the public tree as a FILENAME while every line of the
|
|
16
|
+
// file is clean, so the path is scanned before its contents.
|
|
17
|
+
if (privacyPattern.test(name)) issues.push(`PRIVACY_NAME:${name}`);
|
|
18
|
+
for (const [i, original] of text.split('\n').entries()) {
|
|
19
|
+
let line = original;
|
|
20
|
+
if (name === 'LICENSE' && line === `Copyright (c) 2026 ${personal[0].toUpperCase()}${personal.slice(1)}`) continue;
|
|
21
|
+
// Resolved policy. Two public identifiers are required by the repository and are exempt:
|
|
22
|
+
// the owning account on the exact CODEOWNERS line, and the supported host's product name.
|
|
23
|
+
// Everything else the pattern names stays forbidden, including the same account string
|
|
24
|
+
// anywhere other than that line or a repository URL.
|
|
25
|
+
if (name === '.github/CODEOWNERS' && line === `* @${owner}`) continue;
|
|
26
|
+
// The product name is exempt as a name, never as a path segment. Exempting it outright
|
|
27
|
+
// would let a local working directory through, so path-shaped uses are caught first.
|
|
28
|
+
// Normalise the spellings a path can take before testing: an escaped space, surrounding
|
|
29
|
+
// quotes, and a Windows separator all hide the same path from a naive pattern.
|
|
30
|
+
const normalised = line.replace(/\\ /g, ' ').replace(/["']/g, '');
|
|
31
|
+
const productPath = new RegExp(`(?:~|\\$HOME|%USERPROFILE%|[A-Za-z]:)?[\\\\/]${product}(?=[\\\\/ ]|$)`, 'i');
|
|
32
|
+
if (productPath.test(normalised)) { issues.push(`PRIVACY:${name}:${i + 1}`); continue; }
|
|
33
|
+
line = line.replaceAll(product, 'PUBLIC_PRODUCT');
|
|
34
|
+
// Only exact repository URL namespaces, never the whole line around a URL. npm's
|
|
35
|
+
// `github:owner/repo` shorthand is the same repository reference in the form a package
|
|
36
|
+
// manager takes, so it is exempt in exactly that spelling and no other.
|
|
37
|
+
line = line.replace(new RegExp(`https://(?:github\\.com|raw\\.githubusercontent\\.com)/${owner}/website-build-skill(?=[/\\s)"'.\\x60]|$)`, 'g'), 'REPOSITORY');
|
|
38
|
+
line = line.replace(new RegExp(`github:${owner}/website-build-skill(?=[\\s)"'.\\x60]|$)`, 'g'), 'REPOSITORY');
|
|
39
|
+
if (privacyPattern.test(line) || /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i.test(line)) issues.push(`PRIVACY:${name}:${i + 1}`);
|
|
40
|
+
if (line.includes(String.fromCharCode(0x2014))) issues.push(`VOICE:${name}:${i + 1}`);
|
|
41
|
+
}
|
|
42
|
+
return issues;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function visibleMarkdown(text) {
|
|
46
|
+
let fence = null;
|
|
47
|
+
return text.split('\n').map(line => {
|
|
48
|
+
const m = line.match(/^\s*(`{3,}|~{3,})/);
|
|
49
|
+
if (m) {
|
|
50
|
+
if (!fence) fence = m[1];
|
|
51
|
+
else if (m[1][0] === fence[0] && m[1].length >= fence.length) fence = null;
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
return fence ? '' : line.replace(/`[^`]*`/g, '');
|
|
55
|
+
}).join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fenced blocks, closed the way a Markdown reader closes them: same marker, at least as
|
|
59
|
+
// long, indented up to three spaces. Returns each block's body.
|
|
60
|
+
export function fenced(text) {
|
|
61
|
+
const blocks = [];
|
|
62
|
+
let marker = null, body = [];
|
|
63
|
+
for (const line of text.replace(/\r\n/g, '\n').split('\n')) {
|
|
64
|
+
const open = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
65
|
+
if (!marker) { if (open) { marker = open[1]; body = []; } continue; }
|
|
66
|
+
if (open && open[1][0] === marker[0] && open[1].length >= marker.length && !line.slice(line.indexOf(open[1]) + open[1].length).trim()) { blocks.push(body.join('\n')); marker = null; continue; }
|
|
67
|
+
body.push(line);
|
|
68
|
+
}
|
|
69
|
+
return blocks;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function anchors(text) {
|
|
73
|
+
const result = new Set(), counts = new Map();
|
|
74
|
+
for (const match of visibleMarkdown(text).matchAll(/^#{1,6}\s+(.+?)\s*#*$/gm)) {
|
|
75
|
+
const base = match[1].toLowerCase().replace(/<[^>]*>/g, '').replace(/[^\p{L}\p{N}_\-\s]/gu, '').replace(/ /g, '-');
|
|
76
|
+
const count = counts.get(base) || 0;
|
|
77
|
+
result.add(count ? `${base}-${count}` : base); counts.set(base, count + 1);
|
|
78
|
+
}
|
|
79
|
+
for (const m of text.matchAll(/\b(?:id|name)=["']([^"']+)["']/g)) result.add(m[1]);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function linkIssues(root, name, text) {
|
|
84
|
+
const body = visibleMarkdown(text), urls = [], issues = [], refs = new Map();
|
|
85
|
+
for (const m of body.matchAll(/^\s*\[([^\]]+)\]:\s*(<[^>]+>|\S+)/gm)) refs.set(m[1].toLowerCase(), m[2].replace(/^<|>$/g, ''));
|
|
86
|
+
for (const m of body.matchAll(/!?\[[^\]\n]*\]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^\n]*?["'])?\s*\)/g)) urls.push(m[1].replace(/^<|>$/g, ''));
|
|
87
|
+
for (const m of body.matchAll(/!?\[([^\]\n]+)\]\[([^\]\n]*)\]/g)) {
|
|
88
|
+
const key = (m[2] || m[1]).toLowerCase();
|
|
89
|
+
if (!refs.has(key)) issues.push(`LINK:${name}:undefined reference ${key}`);
|
|
90
|
+
else urls.push(refs.get(key));
|
|
91
|
+
}
|
|
92
|
+
for (const m of body.matchAll(/(?:href|src)=["']([^"']+)["']/g)) urls.push(m[1]);
|
|
93
|
+
urls.push(...refs.values());
|
|
94
|
+
for (const url of urls) {
|
|
95
|
+
if (/^(?:https?:|mailto:|data:|\/\/)/i.test(url)) continue;
|
|
96
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) { issues.push(`LINK:${name}:unsupported URI`); continue; }
|
|
97
|
+
try {
|
|
98
|
+
const [pathQuery, fragment] = url.split('#'), path = decodeURIComponent(pathQuery.split('?')[0]);
|
|
99
|
+
const target = path ? (path.startsWith('/') ? path.slice(1) : posix.normalize(posix.join(posix.dirname(name), path))) : name;
|
|
100
|
+
owned(root, target);
|
|
101
|
+
if (fragment && target.endsWith('.md') && !anchors(read(root, target)).has(decodeURIComponent(fragment))) throw Error('missing heading');
|
|
102
|
+
} catch { issues.push(`LINK:${name}:unresolved ${url}`); }
|
|
103
|
+
}
|
|
104
|
+
return issues;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Before the installer existed, an unlabelled `npx` line was the lie. Now that it exists,
|
|
108
|
+
// a line still labelled planned or unavailable is the lie, and it is the one that survives
|
|
109
|
+
// a release because nobody rereads a caveat. The direction is taken from the package itself.
|
|
110
|
+
export function installClaimIssues(name, text, published = false) {
|
|
111
|
+
const issues = [];
|
|
112
|
+
const command = /\b(?:npx|npm\s+(?:exec|install|i)|pnpm\s+(?:dlx|add)|yarn\s+(?:dlx|add)|bunx)\s+.*website-build-skill/;
|
|
113
|
+
for (const [i, line] of text.split('\n').entries()) {
|
|
114
|
+
const at = line.search(command);
|
|
115
|
+
// Only a caveat standing in FRONT of the command labels that command. The same words
|
|
116
|
+
// after it are usually an honest limitation about something else on the same line.
|
|
117
|
+
const labelled = /planned|unavailable/i.test(at < 0 ? line : line.slice(0, at));
|
|
118
|
+
if (at >= 0 && !labelled && !published) issues.push(`INSTALL_CLAIM:${name}:${i+1}`);
|
|
119
|
+
if (at >= 0 && labelled && published) issues.push(`STALE_CLAIM:${name}:${i+1}`);
|
|
120
|
+
// Nothing in this package is installed by piping a script into a shell, published or not.
|
|
121
|
+
if (/\b(?:curl|wget)\b.*\|\s*(?:sh|bash)/.test(line) && !labelled) issues.push(`INSTALL_CLAIM:${name}:${i+1}`);
|
|
122
|
+
}
|
|
123
|
+
return issues;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Every pinned action SHA, mapped to the upstream tag it actually is. Verified against the
|
|
127
|
+
// action repository's own tag list on the date it was added. A pin absent here is unverified.
|
|
128
|
+
const PINNED_VERSIONS = {
|
|
129
|
+
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': 'v7.0.1',
|
|
130
|
+
'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020': 'v7.0.0',
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function workflowIssues(name, text) {
|
|
134
|
+
const issues = [];
|
|
135
|
+
let workflow;
|
|
136
|
+
try { workflow = JSON.parse(text); }
|
|
137
|
+
catch { return [`WORKFLOW_FORMAT:${name}:use the JSON subset of YAML for structural validation`]; }
|
|
138
|
+
// One exception, as narrow as it can be written: npm trusted publishing exchanges an OIDC
|
|
139
|
+
// token for a short-lived credential, so the publish job of the release workflow needs
|
|
140
|
+
// id-token: write. No other workflow, no other job, and no other permission value.
|
|
141
|
+
const publisher = name === '.github/workflows/release.yml';
|
|
142
|
+
const checkPermissions = (permissions, location, job) => {
|
|
143
|
+
if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) issues.push(`WORKFLOW_PERMISSIONS:${location}`);
|
|
144
|
+
else for (const [key, value] of Object.entries(permissions)) {
|
|
145
|
+
if (key === 'contents' && value === 'read') continue;
|
|
146
|
+
if (value === 'none') continue;
|
|
147
|
+
if (publisher && job === 'publish' && key === 'id-token' && value === 'write') continue;
|
|
148
|
+
issues.push(`WORKFLOW_PERMISSIONS:${location}:${key}`);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
checkPermissions(workflow.permissions, name);
|
|
152
|
+
if (!workflow.concurrency || workflow.concurrency['cancel-in-progress'] !== true) issues.push(`WORKFLOW_CONCURRENCY:${name}`);
|
|
153
|
+
// A trigger can be written as an object, a bare string or an array. Checking only the
|
|
154
|
+
// object form let `on: pull_request_target` through as a string.
|
|
155
|
+
const triggers = workflow.on === undefined ? []
|
|
156
|
+
: typeof workflow.on === 'string' ? [workflow.on]
|
|
157
|
+
: Array.isArray(workflow.on) ? workflow.on.filter(t => typeof t === 'string')
|
|
158
|
+
: Object.keys(workflow.on);
|
|
159
|
+
const forbiddenTriggers = ['pull_request_target', 'workflow_run', 'issue_comment'];
|
|
160
|
+
for (const trigger of triggers) if (forbiddenTriggers.includes(trigger)) issues.push(`WORKFLOW_TRIGGER:${name}:${trigger}`);
|
|
161
|
+
// Attacker-controlled event data interpolated straight into a shell is command injection.
|
|
162
|
+
// The safe form passes the value through `env:` and reads it as a variable.
|
|
163
|
+
const injectable = /\$\{\{\s*(?:github\.event\b|github\.head_ref\b|inputs\.|github\.actor\b)[^}]*\}\}/;
|
|
164
|
+
const visitRun = obj => {
|
|
165
|
+
if (!obj || typeof obj !== 'object') return;
|
|
166
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
167
|
+
if (key === 'run' && typeof value === 'string' && injectable.test(value)) issues.push(`WORKFLOW_INJECTION:${name}`);
|
|
168
|
+
visitRun(value);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
visitRun(workflow);
|
|
172
|
+
const visitUses = obj => {
|
|
173
|
+
if (!obj || typeof obj !== 'object') return;
|
|
174
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
175
|
+
if (key === 'uses' && (typeof value !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+@[a-f0-9]{40}$/.test(value))) issues.push(`ACTION_PIN:${name}`);
|
|
176
|
+
if (key === 'uses' && typeof value === 'string' && value.startsWith('actions/checkout@') && obj.with?.['persist-credentials'] !== false) issues.push(`WORKFLOW_CREDENTIALS:${name}`);
|
|
177
|
+
// A step name that quotes a version must quote the version actually pinned. Dependabot
|
|
178
|
+
// moves the SHA and leaves the label, so without this the workflow lies after every bump.
|
|
179
|
+
if (key === 'uses' && typeof value === 'string' && typeof obj.name === 'string') {
|
|
180
|
+
const labelled = obj.name.match(/\bv\d+(?:\.\d+)*\b/);
|
|
181
|
+
const action = value.split('@')[0].split('/').pop();
|
|
182
|
+
if (labelled && !obj.name.includes(`${action} ${labelled[0]}`)) issues.push(`ACTION_LABEL:${name}:${obj.name}`);
|
|
183
|
+
if (labelled && !PINNED_VERSIONS[value]) issues.push(`ACTION_LABEL:${name}:unverified pin ${value}`);
|
|
184
|
+
else if (labelled && PINNED_VERSIONS[value] !== labelled[0]) issues.push(`ACTION_LABEL:${name}:${obj.name} pins ${PINNED_VERSIONS[value]}`);
|
|
185
|
+
}
|
|
186
|
+
visitUses(value);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
visitUses(workflow);
|
|
190
|
+
for (const [jobName, job] of Object.entries(workflow.jobs || {})) {
|
|
191
|
+
if (job.permissions !== undefined) checkPermissions(job.permissions, `${name}:${jobName}`, jobName);
|
|
192
|
+
if (!job['timeout-minutes']) issues.push(`WORKFLOW_TIMEOUT:${name}:${jobName}`);
|
|
193
|
+
}
|
|
194
|
+
return issues;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A conservative editorial gate: every changed/new public Markdown document waits
|
|
198
|
+
// for review. A checksum is review scope, not evidence that a factual claim is true.
|
|
199
|
+
export function claimIssues(root, names) {
|
|
200
|
+
const issues = [], ledger = JSON.parse(read(root, 'docs/evidence/content-review.json'));
|
|
201
|
+
for (const name of names.filter(n => n.endsWith('.md') && !n.startsWith(`${CORE}/`) && !n.startsWith('docs/bundles/') && !n.startsWith('test/fixtures/'))) {
|
|
202
|
+
const entry = ledger.files[name];
|
|
203
|
+
if (!entry || entry.sha256 !== digest(readFileSync(owned(root, name)))) issues.push(`CLAIM_EVIDENCE:${name}:unreviewed prose change`);
|
|
204
|
+
else if (entry.disposition !== 'instructions-and-qualified-status-only') {
|
|
205
|
+
if (!Array.isArray(entry.claims) || !entry.claims.length) issues.push(`CLAIM_EVIDENCE:${name}:missing claims`);
|
|
206
|
+
for (const claim of entry.claims || []) {
|
|
207
|
+
try {
|
|
208
|
+
if (!claim.text || !read(root, name).includes(claim.text) || !claim.evidence?.startsWith('docs/evidence/')) throw Error('claim not bound');
|
|
209
|
+
const receipt = JSON.parse(read(root, claim.evidence));
|
|
210
|
+
if (!receipt.command || !receipt.environment || !receipt.date || receipt.exitCode !== 0 || !receipt.artifactSha256 || !receipt.observed) throw Error('incomplete receipt');
|
|
211
|
+
} catch { issues.push(`CLAIM_EVIDENCE:${name}:missing evidence receipt`); }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return issues;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function check(root, options = {}) {
|
|
219
|
+
const issues = [], names = files(root);
|
|
220
|
+
// A published package is one whose bin names a real entry point, read from package.json
|
|
221
|
+
// rather than assumed, so the claim checks below follow the package instead of the prose.
|
|
222
|
+
let published = false;
|
|
223
|
+
try {
|
|
224
|
+
const binEntry = JSON.parse(read(root, 'package.json')).bin;
|
|
225
|
+
const entry = typeof binEntry === 'string' ? binEntry : binEntry?.['website-build-skill'];
|
|
226
|
+
published = Boolean(entry) && !/UNAVAILABLE/.test(read(root, entry));
|
|
227
|
+
} catch { published = false; }
|
|
228
|
+
const protect = (name, fn) => { try { fn(); } catch (e) { issues.push(`${name}:${e.message}`); } };
|
|
229
|
+
for (const name of names) {
|
|
230
|
+
const text = read(root, name);
|
|
231
|
+
issues.push(...privacyIssues(name, text, options));
|
|
232
|
+
if (name.endsWith('.md') || name === 'llms.txt') issues.push(...linkIssues(root, name, text));
|
|
233
|
+
if (name.endsWith('.md')) issues.push(...installClaimIssues(name, text, published));
|
|
234
|
+
if (/^\.github\/workflows\/.*\.ya?ml$/.test(name)) issues.push(...workflowIssues(name, text));
|
|
235
|
+
}
|
|
236
|
+
protect('FLOOR', () => {
|
|
237
|
+
for (const entry of JSON.parse(read(root, 'docs/evidence/floor.json')).files) if (!names.includes(entry.path) || !read(root, entry.path).trim()) issues.push(`FLOOR:${entry.path}:missing or empty`);
|
|
238
|
+
});
|
|
239
|
+
protect('MANIFEST', () => {
|
|
240
|
+
const m = manifest(root);
|
|
241
|
+
if (m.schemaVersion !== 1 || m.skillName !== 'website-build-skill' || JSON.stringify(m.roleOrder) !== JSON.stringify(roles)) issues.push('MANIFEST:identity or role order');
|
|
242
|
+
const assetNames = m.assets.map(a => a.path);
|
|
243
|
+
if (new Set(assetNames).size !== assetNames.length) issues.push('MANIFEST:duplicate asset');
|
|
244
|
+
for (const name of files(root, CORE)) if (!assetNames.includes(name.slice(CORE.length + 1))) issues.push(`MANIFEST:unlisted asset ${name}`);
|
|
245
|
+
for (const a of m.assets) {
|
|
246
|
+
if (!/^[a-zA-Z0-9_./-]+$/.test(a.path) || a.path.split('/').includes('..') || a.path.startsWith('/')) throw Error('invalid canonical asset path');
|
|
247
|
+
const bytes = readFileSync(owned(root, `${CORE}/${a.path}`));
|
|
248
|
+
if (a.path !== 'manifest.json' && digest(bytes) !== a.sha256) issues.push(`SOURCE_HASH:${a.path}`);
|
|
249
|
+
}
|
|
250
|
+
const archive = m.assets.find(a => a.path === ARCHIVE);
|
|
251
|
+
if (archive?.active !== false || archive?.bundles.length || digest(readFileSync(owned(root, `${CORE}/${ARCHIVE}`))) !== ARCHIVE_SHA) issues.push('ARCHIVE_BYTES:changed archive');
|
|
252
|
+
for (const [name, members] of Object.entries(m.bundles)) {
|
|
253
|
+
if (new Set(members).size !== members.length) issues.push(`BUNDLE_MEMBERSHIP:${name}:duplicate`);
|
|
254
|
+
for (const member of members) if (!m.assets.find(a => a.path === member)?.active || member === ARCHIVE) issues.push(`ARCHIVE_EXCLUSION:${name}`);
|
|
255
|
+
}
|
|
256
|
+
for (const route of m.stageRoutes) for (const member of route.assets) if (!m.assets.find(a => a.path === member)?.active) issues.push(`ARCHIVE_EXCLUSION:route ${route.id}`);
|
|
257
|
+
for (const role of roles) {
|
|
258
|
+
const body = read(root, `${CORE}/roles/${role}.md`);
|
|
259
|
+
for (const title of headings) {
|
|
260
|
+
const part = body.split(`## ${title}\n`)[1]?.split('\n## ')[0].trim();
|
|
261
|
+
if (!part) issues.push(`ROLE_SECTIONS:${role}:${title}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const question = read(root, `${CORE}/templates/install-choice.txt`).trimEnd();
|
|
265
|
+
if (!read(root, 'README.md').includes(question) || !read(root, `${CORE}/SKILL.md`).includes(question)) issues.push('CHOICE_DRIFT:verbatim question');
|
|
266
|
+
const prompts = read(root, 'docs/bundles/prompts.md');
|
|
267
|
+
if (prompts.includes(`BEGIN SOURCE: ${ARCHIVE}`) || prompts.includes(read(root, `${CORE}/${ARCHIVE}`).split('\n').slice(8).join('\n').trim())) issues.push('ARCHIVE_EXCLUSION:active prompt bundle');
|
|
268
|
+
});
|
|
269
|
+
// The starter is the whole payload for a chat-only user. It names the route they will
|
|
270
|
+
// follow, so it cannot quietly drop the gates that stand between a candidate and release.
|
|
271
|
+
// A substring gate cannot read intent: an author who writes "skip Protect" defeats it. It
|
|
272
|
+
// catches the failure that actually happened, a route that silently lost four stages.
|
|
273
|
+
protect('STARTER', () => {
|
|
274
|
+
// The route is read from SKILL.md, so the starter cannot drift from the canonical method.
|
|
275
|
+
const route = read(root, `${CORE}/SKILL.md`).split('\nFollow Research')[1]?.split('.')[0];
|
|
276
|
+
const stages = route === undefined ? [] : ['Research', ...route.split(/,|\band\b/)].map(stage => stage.trim()).filter(Boolean);
|
|
277
|
+
if (stages.length < 8) { issues.push(`STARTER_ROUTE:${CORE}/SKILL.md:canonical route unreadable`); return; }
|
|
278
|
+
const starters = new Map();
|
|
279
|
+
for (const name of ['README.md', 'adapters/zero-install.md']) {
|
|
280
|
+
// Fences are parsed rather than split on, because an indented or longer closing fence
|
|
281
|
+
// ended the block for a reader while a naive split kept counting the prose after it.
|
|
282
|
+
const blocks = fenced(read(root, name)).filter(block => block.startsWith('Use option 1'));
|
|
283
|
+
if (blocks.length !== 1) { issues.push(`STARTER_ROUTE:${name}:${blocks.length ? 'ambiguous starter' : 'missing starter'}`); continue; }
|
|
284
|
+
const [starter] = blocks;
|
|
285
|
+
starters.set(name, starter);
|
|
286
|
+
// In order, not merely present: a sentence that names four stages while dropping the
|
|
287
|
+
// rest satisfies a membership test and still describes a different route.
|
|
288
|
+
let cursor = 0;
|
|
289
|
+
for (const stage of stages) {
|
|
290
|
+
const at = starter.indexOf(stage, cursor);
|
|
291
|
+
if (at < 0) { issues.push(`STARTER_ROUTE:${name}:${stage}`); break; }
|
|
292
|
+
cursor = at + stage.length;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (starters.size === 2 && new Set(starters.values()).size !== 1) issues.push('STARTER_DRIFT:README and zero-install starters differ');
|
|
296
|
+
});
|
|
297
|
+
protect('BUNDLE_DRIFT', () => {
|
|
298
|
+
for (const [name, body] of Object.entries(generated(root))) if (read(root, name) !== body) issues.push(`BUNDLE_DRIFT:${name}`);
|
|
299
|
+
});
|
|
300
|
+
protect('CLAIM_EVIDENCE', () => issues.push(...claimIssues(root, names)));
|
|
301
|
+
protect('PACKAGE', () => {
|
|
302
|
+
const p = JSON.parse(read(root, 'package.json'));
|
|
303
|
+
// The gate used to refuse a bin, engines and publication outright, because the installer
|
|
304
|
+
// did not exist and the package would have shipped an entry point that only printed
|
|
305
|
+
// UNAVAILABLE. Now that the installer is real, the same honesty is enforced the other
|
|
306
|
+
// way round: the entry has to exist, the files it needs have to ship, and the engine
|
|
307
|
+
// floor has to be one continuous integration actually runs.
|
|
308
|
+
if (p.private) issues.push('PACKAGE_HONESTY:private package cannot be published');
|
|
309
|
+
// npm runs preinstall, install, postinstall and prepare on the consumer's machine, before
|
|
310
|
+
// they have read anything. prepublishOnly and prepack run at publish time and can change
|
|
311
|
+
// the bytes after they were tested. This package needs none of the six.
|
|
312
|
+
for (const hook of ['preinstall', 'install', 'postinstall', 'prepare', 'prepublishOnly', 'prepack'])
|
|
313
|
+
if (p.scripts?.[hook]) issues.push(`PACKAGE_HONESTY:${hook} runs outside the test that approved these bytes`);
|
|
314
|
+
const binEntry = typeof p.bin === 'string' ? p.bin : p.bin?.[p.name];
|
|
315
|
+
if (binEntry !== 'bin/website-build-skill.mjs') issues.push('PACKAGE_HONESTY:bin must name the real entry point');
|
|
316
|
+
else if (/UNAVAILABLE/.test(read(root, binEntry))) issues.push('PACKAGE_HONESTY:bin is still the unavailable sentinel');
|
|
317
|
+
// A published tarball that omits any of these installs nothing on a stranger's machine.
|
|
318
|
+
// A negation can empty one of them while the entry it negates is still listed, so the
|
|
319
|
+
// list is compared on its effective entries, and `src` and `src/` are the same entry.
|
|
320
|
+
const published = (p.files ?? []).map(entry => String(entry).replace(/\/+$/, ''));
|
|
321
|
+
if (published.some(entry => entry.startsWith('!'))) issues.push('PACKAGE_FILES:a negated entry can empty a published path');
|
|
322
|
+
for (const needed of ['bin', 'src', 'skills', 'docs', 'README.md', 'LICENSE'])
|
|
323
|
+
if (!published.includes(needed)) issues.push(`PACKAGE_FILES:${needed} is not published`);
|
|
324
|
+
const floor = p.engines?.node?.match(/(\d+)/)?.[1];
|
|
325
|
+
const checks = JSON.parse(read(root, '.github/workflows/check.yml')).jobs?.checks;
|
|
326
|
+
const matrix = checks?.strategy?.matrix?.node;
|
|
327
|
+
if (!floor) issues.push('PACKAGE_HONESTY:no engine floor declared');
|
|
328
|
+
else if (!Array.isArray(matrix) || !matrix.some(version => String(version).split('.')[0] === floor)) issues.push(`ENGINE_UNTESTED:node ${floor} is claimed but not in the check matrix`);
|
|
329
|
+
// A matrix nobody reads proves nothing: the runtime step has to select from it.
|
|
330
|
+
else if (!(checks.steps ?? []).some(step => typeof step.uses === 'string' && step.uses.startsWith('actions/setup-node@') && step.with?.['node-version'] === '${{ matrix.node }}'))
|
|
331
|
+
issues.push('ENGINE_UNTESTED:the runtime step does not select the matrix version');
|
|
332
|
+
// `engines` is refused above because it advertises consumer functionality this package
|
|
333
|
+
// does not publish, so the maintainer runtime is stated by a guard that runs first and
|
|
334
|
+
// by the README instead. Node 20 otherwise fails the flag with `bad option` and no cause.
|
|
335
|
+
// The install question is the pitch. Promising a one-command team while no installer
|
|
336
|
+
// ships is the one claim in it that a first-run user can act on and be wrong. The card
|
|
337
|
+
// is wrapped text, so a claim can straddle a line break; whitespace is collapsed first.
|
|
338
|
+
const choice = read(root, `${CORE}/templates/install-choice.txt`).replace(/\s+/g, ' ');
|
|
339
|
+
// Nothing in this package starts a worker, installer or not, so the card can never say
|
|
340
|
+
// the team is ready. Without a bin it must also say the one-command install is planned.
|
|
341
|
+
if (/they are ready/i.test(choice)) issues.push('CHOICE_HONESTY:no installer creates a running worker');
|
|
342
|
+
if (!p.bin && !/one-command install is planned/i.test(choice)) issues.push('CHOICE_HONESTY:manual-setup statement missing');
|
|
343
|
+
const settings = read(root, 'REPO_SETTINGS.md');
|
|
344
|
+
const topics = settings.split('## Topics')[1].split('## Creation')[0].split('\n').filter(s => /^- [a-z0-9-]+$/.test(s)).map(s => s.slice(2));
|
|
345
|
+
if (JSON.stringify(topics) !== JSON.stringify(p.keywords) || topics.length > 20 || topics.some(t => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(t) || t.length >= 50)) issues.push('TOPICS:invalid or mismatched keywords');
|
|
346
|
+
if (p.description.length >= 350) issues.push('DESCRIPTION:too long');
|
|
347
|
+
const plugin = JSON.parse(read(root, '.claude-plugin/plugin.json'));
|
|
348
|
+
if (plugin.version !== p.version || p.version !== manifest(root).packageVersion) issues.push('VERSION:metadata drift');
|
|
349
|
+
});
|
|
350
|
+
return [...new Set(issues)];
|
|
351
|
+
}
|