nimiq-branding-cli 1.1.0 → 1.2.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/.audit/report.json +71 -0
- package/.audit/report.md +33 -0
- package/AUDIT.md +60 -0
- package/LINT.md +158 -0
- package/README.md +47 -4
- package/align/canonical.json +44 -0
- package/audit/learnings.json +51 -0
- package/bin/nq.js +90 -5
- package/hooks/ci-additions.md +53 -0
- package/hooks/pre-commit +10 -0
- package/hooks/session-start.sh +3 -0
- package/hooks/stack-align.yml +75 -0
- package/package.json +4 -4
- package/registry/SKILL-BLOCK.md +20 -0
- package/registry/components/account-header/meta.json +11 -1
- package/registry/components/account-list/meta.json +9 -1
- package/registry/components/account-ring/meta.json +9 -1
- package/registry/components/address-display/html/address-display.css +17 -13
- package/registry/components/address-display/html/demo.html +8 -10
- package/registry/components/address-display/meta.json +37 -7
- package/registry/components/address-display/vue/AddressDisplay.vue +11 -9
- package/registry/components/address-input/meta.json +53 -10
- package/registry/components/amount/meta.json +60 -11
- package/registry/components/amount-input/meta.json +55 -10
- package/registry/components/amount-with-fee/meta.json +58 -11
- package/registry/components/app-showcase-card/meta.json +17 -4
- package/registry/components/backup-banner/meta.json +29 -5
- package/registry/components/balance-distribution/meta.json +25 -5
- package/registry/components/buttons/meta.json +38 -7
- package/registry/components/card/meta.json +20 -4
- package/registry/components/close-button/meta.json +19 -4
- package/registry/components/consensus-icon/meta.json +26 -5
- package/registry/components/copyable/meta.json +25 -5
- package/registry/components/fiat-amount/meta.json +49 -9
- package/registry/components/flag-hex/html/demo.html +55 -0
- package/registry/components/flag-hex/html/flag-hex.css +21 -0
- package/registry/components/flag-hex/html/flag-hex.html +38 -0
- package/registry/components/flag-hex/meta.json +43 -0
- package/registry/components/flag-hex/vue/FlagHex.vue +98 -0
- package/registry/components/hero-section/meta.json +9 -4
- package/registry/components/honeycomb-band/meta.json +47 -9
- package/registry/components/identicon/meta.json +8 -1
- package/registry/components/label-input/meta.json +49 -9
- package/registry/components/loading-spinner/meta.json +19 -4
- package/registry/components/page-body/meta.json +23 -5
- package/registry/components/page-footer/meta.json +22 -5
- package/registry/components/page-header/meta.json +43 -8
- package/registry/components/payment-info-line/meta.json +10 -1
- package/registry/components/price-chart/meta.json +67 -11
- package/registry/components/qr-code/meta.json +22 -5
- package/registry/components/search-bar/meta.json +25 -5
- package/registry/components/select-bar/meta.json +37 -7
- package/registry/components/slider-toggle/meta.json +45 -8
- package/registry/components/small-page/meta.json +19 -4
- package/registry/components/status-alert/meta.json +37 -7
- package/registry/components/status-screen/meta.json +70 -13
- package/registry/components/swap-balance-bar/meta.json +8 -1
- package/registry/components/timer/meta.json +54 -10
- package/registry/components/toast-notification/meta.json +49 -9
- package/registry/components/tooltip/meta.json +69 -13
- package/registry/components/transaction-list/meta.json +9 -1
- package/registry/index.json +10 -0
- package/schemas/nimiq-stack.v1.json +103 -0
- package/scripts/align.mjs +435 -0
- package/scripts/apply-safe-bump.mjs +47 -0
- package/scripts/audit.mjs +247 -0
- package/scripts/bootstrap-provenance.mjs +126 -0
- package/scripts/hooks.mjs +152 -0
- package/scripts/lint.mjs +333 -0
- package/scripts/scaffold.mjs +391 -0
- package/scripts/sync-skill.mjs +84 -0
- package/test/align.test.mjs +166 -0
- package/test/scaffold.test.mjs +103 -0
- package/upstream-pins.json +35 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// nq audit — the self-learning branding-drift engine.
|
|
2
|
+
//
|
|
3
|
+
// Branding accuracy has two axes:
|
|
4
|
+
// (1) our vendored upstream pin == the LIVE Nimiq design (this file)
|
|
5
|
+
// (2) our port == the vendored upstream truth render (scripts/verify.mjs)
|
|
6
|
+
// `nq verify` only covers (2) against a FROZEN snapshot. This audit covers (1):
|
|
7
|
+
// it asks "has the live Nimiq source moved away from what we ported?", attributes
|
|
8
|
+
// any drift to the exact components it touches (via meta.json `source.files`),
|
|
9
|
+
// classifies each change through an accumulated learnings store, runs the pixel
|
|
10
|
+
// verify, and emits a verdict the weekly workflow acts on (auto-PR safe / alert risky).
|
|
11
|
+
//
|
|
12
|
+
// Works locally (uses the gitignored upstream/<repo> clones) AND in CI (no clones:
|
|
13
|
+
// `git ls-remote` for live tips + the GitHub compare API for changed files).
|
|
14
|
+
//
|
|
15
|
+
// Usage: node scripts/audit.mjs [--skip-verify] [--json]
|
|
16
|
+
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const COMPONENTS = join(ROOT, 'registry', 'components');
|
|
24
|
+
const SKIP_VERIFY = process.argv.includes('--skip-verify');
|
|
25
|
+
const JSON_ONLY = process.argv.includes('--json');
|
|
26
|
+
|
|
27
|
+
// ---------- tiny helpers ----------
|
|
28
|
+
function sh(cmd, args, opts = {}) {
|
|
29
|
+
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...opts }).trim();
|
|
30
|
+
}
|
|
31
|
+
function try_(fn, fallback = null) { try { return fn(); } catch { return fallback; } }
|
|
32
|
+
|
|
33
|
+
// glob ("wallet:**/*Sentry*") -> RegExp. Supports ** (any incl. /), * (any excl. /).
|
|
34
|
+
function globToRe(glob) {
|
|
35
|
+
let re = '';
|
|
36
|
+
for (let i = 0; i < glob.length; i++) {
|
|
37
|
+
const c = glob[i];
|
|
38
|
+
if (c === '*') {
|
|
39
|
+
if (glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; }
|
|
40
|
+
else re += '[^/]*';
|
|
41
|
+
} else if ('.+?^${}()|[]\\'.includes(c)) re += '\\' + c;
|
|
42
|
+
else re += c;
|
|
43
|
+
}
|
|
44
|
+
return new RegExp('^' + re + '$');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------- inputs ----------
|
|
48
|
+
async function loadPins() {
|
|
49
|
+
return JSON.parse(await readFile(join(ROOT, 'upstream-pins.json'), 'utf8')).repos;
|
|
50
|
+
}
|
|
51
|
+
async function loadLearnings() {
|
|
52
|
+
const p = join(ROOT, 'audit', 'learnings.json');
|
|
53
|
+
if (!existsSync(p)) return { rules: [] };
|
|
54
|
+
return JSON.parse(await readFile(p, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
// "repo:path" -> [componentName...] and componentName -> {repo, files}
|
|
57
|
+
async function loadProvenance() {
|
|
58
|
+
const index = {}; const byComponent = {};
|
|
59
|
+
for (const name of (await readdir(COMPONENTS)).sort()) {
|
|
60
|
+
const meta = JSON.parse(await readFile(join(COMPONENTS, name, 'meta.json'), 'utf8'));
|
|
61
|
+
const src = meta.source ?? { repo: 'unknown', files: [] };
|
|
62
|
+
byComponent[name] = src;
|
|
63
|
+
for (const f of src.files ?? []) (index[f] ??= []).push(name);
|
|
64
|
+
}
|
|
65
|
+
return { index, byComponent };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- upstream drift ----------
|
|
69
|
+
function repoSlug(url) { // https://github.com/nimiq/wallet.git -> nimiq/wallet
|
|
70
|
+
const m = url.match(/github\.com[:/]+([^/]+\/[^/.]+)/);
|
|
71
|
+
return m ? m[1] : null;
|
|
72
|
+
}
|
|
73
|
+
function liveTip(repo, info) {
|
|
74
|
+
const local = join(ROOT, 'upstream', repo);
|
|
75
|
+
if (existsSync(join(local, '.git'))) {
|
|
76
|
+
try_(() => sh('git', ['-C', local, 'fetch', '--quiet', 'origin', info.branch]));
|
|
77
|
+
const tip = try_(() => sh('git', ['-C', local, 'rev-parse', `origin/${info.branch}`]));
|
|
78
|
+
if (tip) return { tip, via: 'local' };
|
|
79
|
+
}
|
|
80
|
+
const ls = try_(() => sh('git', ['ls-remote', info.url, `refs/heads/${info.branch}`]));
|
|
81
|
+
if (ls) return { tip: ls.split(/\s+/)[0], via: 'ls-remote' };
|
|
82
|
+
return { tip: null, via: 'unreachable' };
|
|
83
|
+
}
|
|
84
|
+
function changedFiles(repo, info, base, head) {
|
|
85
|
+
const local = join(ROOT, 'upstream', repo);
|
|
86
|
+
// local clone: direct diff (most reliable, offline)
|
|
87
|
+
if (existsSync(join(local, '.git'))) {
|
|
88
|
+
const out = try_(() => sh('git', ['-C', local, 'diff', '--name-only', `${base}..${head}`]));
|
|
89
|
+
if (out !== null) return out ? out.split('\n') : [];
|
|
90
|
+
}
|
|
91
|
+
// CI: GitHub compare API. Prefer authenticated gh (higher rate limit), else fetch.
|
|
92
|
+
const slug = repoSlug(info.url);
|
|
93
|
+
if (slug) {
|
|
94
|
+
const viaGh = try_(() => sh('gh', ['api', `repos/${slug}/compare/${base}...${head}`, '--jq', '.files[].filename']));
|
|
95
|
+
if (viaGh !== null) return viaGh ? viaGh.split('\n') : [];
|
|
96
|
+
}
|
|
97
|
+
return null; // unknown — drift detected but file list unavailable
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function classify(repoPath, learnings) {
|
|
101
|
+
for (const rule of learnings.rules ?? []) {
|
|
102
|
+
if (globToRe(rule.pattern).test(repoPath)) return rule;
|
|
103
|
+
}
|
|
104
|
+
return null; // unknown -> triage
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- main ----------
|
|
108
|
+
const pins = await loadPins();
|
|
109
|
+
const learnings = await loadLearnings();
|
|
110
|
+
const { index: provIndex, byComponent } = await loadProvenance();
|
|
111
|
+
|
|
112
|
+
const report = {
|
|
113
|
+
generatedAtNote: 'timestamp stamped by caller',
|
|
114
|
+
verdict: 'clean',
|
|
115
|
+
repos: {},
|
|
116
|
+
driftedComponents: [], // components whose upstream source changed
|
|
117
|
+
tokenDrift: [], // framework-level css/scss/token changes
|
|
118
|
+
unknownPaths: [], // changed paths with no learnings rule -> need triage
|
|
119
|
+
verify: { ran: false },
|
|
120
|
+
newComponents: [], // upstream components not yet in the registry
|
|
121
|
+
summary: '',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
let anyDrift = false, riskyDrift = false;
|
|
125
|
+
|
|
126
|
+
for (const [repo, info] of Object.entries(pins)) {
|
|
127
|
+
const { tip, via } = liveTip(repo, info);
|
|
128
|
+
const base = info.pinned;
|
|
129
|
+
const drifted = tip && tip !== base;
|
|
130
|
+
const entry = { branch: info.branch, pinned: base.slice(0, 8), live: tip ? tip.slice(0, 8) : null, pinnedFull: base, liveFull: tip, via, drifted: !!drifted };
|
|
131
|
+
if (drifted) {
|
|
132
|
+
anyDrift = true;
|
|
133
|
+
const files = changedFiles(repo, info, base, tip);
|
|
134
|
+
entry.changedFileCount = files ? files.length : null;
|
|
135
|
+
entry.changedFiles = files ? files.slice(0, 500) : null;
|
|
136
|
+
for (const path of files ?? []) {
|
|
137
|
+
const repoPath = `${repo}:${path}`;
|
|
138
|
+
// does this file feed a registry component?
|
|
139
|
+
const touchedComponents = new Set();
|
|
140
|
+
for (const [provFile, comps] of Object.entries(provIndex)) {
|
|
141
|
+
if (provFile === repoPath) comps.forEach(c => touchedComponents.add(c));
|
|
142
|
+
}
|
|
143
|
+
const rule = classify(repoPath, learnings);
|
|
144
|
+
if (touchedComponents.size) {
|
|
145
|
+
riskyDrift = true; // a real component source moved -> needs re-port review
|
|
146
|
+
report.driftedComponents.push({ repoPath, components: [...touchedComponents], verdict: rule?.verdict ?? 'branding' });
|
|
147
|
+
} else if (rule?.verdict === 'branding' || rule?.verdict === 'token') {
|
|
148
|
+
riskyDrift = true;
|
|
149
|
+
report.tokenDrift.push({ repoPath, rule: rule.pattern, reason: rule.reason });
|
|
150
|
+
} else if (rule?.verdict === 'ignore') {
|
|
151
|
+
// benign churn (deps, telemetry, CI) — known noise, no action
|
|
152
|
+
} else {
|
|
153
|
+
report.unknownPaths.push(repoPath); // no rule -> triage (treated risky)
|
|
154
|
+
riskyDrift = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (files === null) { entry.note = 'drift detected but changed-file list unavailable (no clone + compare API failed)'; riskyDrift = true; }
|
|
158
|
+
}
|
|
159
|
+
report.repos[repo] = entry;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------- new-component radar (best-effort, needs the vue-components clone) ----------
|
|
163
|
+
{
|
|
164
|
+
const vc = join(ROOT, 'upstream', 'vue-components', 'src', 'components');
|
|
165
|
+
if (existsSync(vc)) {
|
|
166
|
+
const ported = new Set(Object.values(byComponent).flatMap(s => (s.files ?? []).filter(f => f.startsWith('vue-components:')).map(f => f.split(':')[1])));
|
|
167
|
+
for (const f of (await readdir(vc)).filter(f => f.endsWith('.vue'))) {
|
|
168
|
+
const path = `src/components/${f}`;
|
|
169
|
+
if (!ported.has(path)) report.newComponents.push(`vue-components:${path}`);
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
report.newComponents = ['(skipped — vue-components clone not present; see FEATURES.md roadmap)'];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------- port fidelity (pixel verify) ----------
|
|
177
|
+
if (!SKIP_VERIFY) {
|
|
178
|
+
try {
|
|
179
|
+
const { verify } = await import(join(ROOT, 'scripts', 'verify.mjs'));
|
|
180
|
+
const names = (await readdir(COMPONENTS)).sort();
|
|
181
|
+
let pass = 0, fail = 0, skip = 0; const failed = [];
|
|
182
|
+
for (const n of names) {
|
|
183
|
+
const r = await verify(n);
|
|
184
|
+
if (r.status === 'pass') pass++;
|
|
185
|
+
else if (r.status === 'skip') skip++;
|
|
186
|
+
else { fail++; failed.push({ name: n, diffPct: r.diffPct }); }
|
|
187
|
+
}
|
|
188
|
+
report.verify = { ran: true, pass, fail, skip, failed };
|
|
189
|
+
if (fail) riskyDrift = true;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
report.verify = { ran: false, error: e.message };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------- verdict ----------
|
|
196
|
+
// clean : nothing moved, ports green
|
|
197
|
+
// safe : upstream moved but ONLY in benign (learnings-classified "ignore") files,
|
|
198
|
+
// no component/token touched, no unknown paths, verify green -> auto-PR pin bump
|
|
199
|
+
// risky : a component source moved, token-relevant change, unknown paths, or verify failed -> alert
|
|
200
|
+
if (!anyDrift && (!report.verify.ran || report.verify.fail === 0)) report.verdict = 'clean';
|
|
201
|
+
else if (anyDrift && !riskyDrift) report.verdict = 'safe';
|
|
202
|
+
else report.verdict = 'risky';
|
|
203
|
+
|
|
204
|
+
report.summary = [
|
|
205
|
+
`verdict: ${report.verdict}`,
|
|
206
|
+
`${Object.values(report.repos).filter(r => r.drifted).length}/${Object.keys(report.repos).length} upstream(s) drifted`,
|
|
207
|
+
`${report.driftedComponents.length} component(s) upstream-touched`,
|
|
208
|
+
`${report.tokenDrift.length} token-drift`,
|
|
209
|
+
`${report.unknownPaths.length} unknown path(s)`,
|
|
210
|
+
report.verify.ran ? `verify ${report.verify.pass}✓/${report.verify.fail}✗/${report.verify.skip}·` : 'verify skipped',
|
|
211
|
+
].join(' · ');
|
|
212
|
+
|
|
213
|
+
// ---------- write report ----------
|
|
214
|
+
await mkdir(join(ROOT, '.audit'), { recursive: true });
|
|
215
|
+
await writeFile(join(ROOT, '.audit', 'report.json'), JSON.stringify(report, null, 2) + '\n');
|
|
216
|
+
await writeFile(join(ROOT, '.audit', 'report.md'), renderMd(report));
|
|
217
|
+
|
|
218
|
+
if (JSON_ONLY) console.log(JSON.stringify(report, null, 2));
|
|
219
|
+
else {
|
|
220
|
+
console.log(`\nnq audit — ${report.summary}\n`);
|
|
221
|
+
for (const [repo, r] of Object.entries(report.repos)) {
|
|
222
|
+
console.log(` ${r.drifted ? '⚠' : '✓'} ${repo.padEnd(15)} ${r.pinned} → ${r.live ?? '?'}${r.drifted ? ` (${r.changedFileCount ?? '?'} files, ${r.via})` : ' up-to-date'}`);
|
|
223
|
+
}
|
|
224
|
+
if (report.driftedComponents.length) { console.log('\n upstream-touched components (need re-port review):'); report.driftedComponents.forEach(d => console.log(` ✗ ${d.components.join(', ')} ← ${d.repoPath}`)); }
|
|
225
|
+
if (report.tokenDrift.length) { console.log('\n token / framework drift:'); report.tokenDrift.forEach(d => console.log(` ! ${d.repoPath} (${d.reason})`)); }
|
|
226
|
+
if (report.unknownPaths.length) { console.log(`\n unknown paths needing triage: ${report.unknownPaths.length} (see .audit/report.md)`); }
|
|
227
|
+
if (report.newComponents.length) { console.log(`\n new-component radar: ${report.newComponents.length} upstream component(s) not in registry`); }
|
|
228
|
+
console.log(`\n full report: .audit/report.md\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// verdict drives the workflow; non-zero only on risky so a CI step can branch on it too
|
|
232
|
+
process.exitCode = report.verdict === 'risky' ? 2 : 0;
|
|
233
|
+
|
|
234
|
+
function renderMd(r) {
|
|
235
|
+
const L = [];
|
|
236
|
+
L.push(`# nq audit report`, ``, `**${r.summary}**`, ``);
|
|
237
|
+
L.push(`## Upstream pins vs live`, ``, `| repo | branch | pinned | live | drifted | changed files |`, `|---|---|---|---|---|---|`);
|
|
238
|
+
for (const [repo, e] of Object.entries(r.repos)) L.push(`| ${repo} | ${e.branch} | \`${e.pinned}\` | \`${e.live ?? '?'}\` | ${e.drifted ? '**yes**' : 'no'} | ${e.drifted ? (e.changedFileCount ?? '?') : '—'} |`);
|
|
239
|
+
L.push(``);
|
|
240
|
+
if (r.driftedComponents.length) { L.push(`## ⚠ Components whose upstream source changed (re-port review)`, ``); for (const d of r.driftedComponents) L.push(`- \`${d.repoPath}\` → **${d.components.join(', ')}**`); L.push(``); }
|
|
241
|
+
if (r.tokenDrift.length) { L.push(`## Token / framework drift`, ``); for (const d of r.tokenDrift) L.push(`- \`${d.repoPath}\` — ${d.reason}`); L.push(``); }
|
|
242
|
+
if (r.unknownPaths.length) { L.push(`## Unknown changed paths (need triage → add a learnings rule)`, ``); for (const p of r.unknownPaths) L.push(`- \`${p}\``); L.push(``); }
|
|
243
|
+
if (r.verify.ran) { L.push(`## Port fidelity (pixel verify)`, ``, `${r.verify.pass} pass · ${r.verify.fail} fail · ${r.verify.skip} skip`, ``); if (r.verify.failed?.length) for (const f of r.verify.failed) L.push(`- ✗ ${f.name} (${f.diffPct}%)`); L.push(``); }
|
|
244
|
+
if (r.newComponents.length) { L.push(`## New-component radar`, ``, `Upstream components with no registry port yet (candidates — cross-ref FEATURES.md):`, ``); for (const n of r.newComponents) L.push(`- \`${n}\``); L.push(``); }
|
|
245
|
+
L.push(`---`, ``, `Verdicts: **clean** (nothing moved) · **safe** (only learnings-"ignore" churn → auto-PR pin bump) · **risky** (component/token/unknown/verify-fail → human review).`);
|
|
246
|
+
return L.join('\n') + '\n';
|
|
247
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// One-time (idempotent) bootstrap: write a structured `source` provenance block
|
|
2
|
+
// into every registry component's meta.json. The audit (scripts/audit.mjs) reads
|
|
3
|
+
// this to attribute an upstream diff to the exact components it touches.
|
|
4
|
+
//
|
|
5
|
+
// `source` schema:
|
|
6
|
+
// { "repo": "<primary repo|original|nimiq-com>",
|
|
7
|
+
// "ref": "<short sha of the primary repo's vendored pin at bootstrap>",
|
|
8
|
+
// "files": ["<repo>:<path within upstream/<repo>>", ...] }
|
|
9
|
+
//
|
|
10
|
+
// `files` entries are "repo:path" strings (primary repo first) so the audit can
|
|
11
|
+
// split on ":" and the format matches audit/learnings.json patterns.
|
|
12
|
+
//
|
|
13
|
+
// Run: node scripts/bootstrap-provenance.mjs [--check]
|
|
14
|
+
// --check : report only, do not write.
|
|
15
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const COMPONENTS = join(ROOT, 'registry', 'components');
|
|
23
|
+
const CHECK = process.argv.includes('--check');
|
|
24
|
+
|
|
25
|
+
// Primary repo + the upstream source file(s) each component was ported from.
|
|
26
|
+
// Derived from meta.notes citations, the @nimiq/vue-components PascalCase
|
|
27
|
+
// mapping, and the FEATURES.md "Built from:" survey. Marketing components are
|
|
28
|
+
// calibrated to nimiq.com screenshots/assets (no vendored git repo → "nimiq-com");
|
|
29
|
+
// flag-hex is an original brand composition ("original").
|
|
30
|
+
const MAP = {
|
|
31
|
+
// --- @nimiq/vue-components ports (upstream/vue-components/src/components/*.vue) ---
|
|
32
|
+
'account-list': { repo: 'vue-components', files: ['vue-components:src/components/AccountList.vue', 'vue-components:src/components/Identicon.vue'] },
|
|
33
|
+
'account-ring': { repo: 'vue-components', files: ['vue-components:src/components/AccountRing.vue', 'vue-components:src/components/Identicon.vue'] },
|
|
34
|
+
'address-display': { repo: 'vue-components', files: ['vue-components:src/components/AddressDisplay.vue'] },
|
|
35
|
+
'address-input': { repo: 'vue-components', files: ['vue-components:src/components/AddressInput.vue'] },
|
|
36
|
+
'amount': { repo: 'vue-components', files: ['vue-components:src/components/Amount.vue'] },
|
|
37
|
+
'amount-input': { repo: 'vue-components', files: ['vue-components:src/components/AmountInput.vue'] },
|
|
38
|
+
'amount-with-fee': { repo: 'vue-components', files: ['vue-components:src/components/AmountWithFee.vue', 'vue-components:src/components/AmountInput.vue'] },
|
|
39
|
+
'close-button': { repo: 'vue-components', files: ['vue-components:src/components/CloseButton.vue'] },
|
|
40
|
+
'copyable': { repo: 'vue-components', files: ['vue-components:src/components/Copyable.vue'] },
|
|
41
|
+
'fiat-amount': { repo: 'vue-components', files: ['vue-components:src/components/FiatAmount.vue'] },
|
|
42
|
+
'identicon': { repo: 'vue-components', files: ['vue-components:src/components/Identicon.vue'] },
|
|
43
|
+
'label-input': { repo: 'vue-components', files: ['vue-components:src/components/LabelInput.vue'] },
|
|
44
|
+
'loading-spinner': { repo: 'vue-components', files: ['vue-components:src/components/LoadingSpinner.vue'] },
|
|
45
|
+
'page-body': { repo: 'vue-components', files: ['vue-components:src/components/PageBody.vue'] },
|
|
46
|
+
'page-footer': { repo: 'vue-components', files: ['vue-components:src/components/PageFooter.vue'] },
|
|
47
|
+
'page-header': { repo: 'vue-components', files: ['vue-components:src/components/PageHeader.vue'] },
|
|
48
|
+
'payment-info-line': { repo: 'vue-components', files: ['vue-components:src/components/PaymentInfoLine.vue', 'vue-components:src/components/Timer.vue', 'vue-components:src/components/Identicon.vue'] },
|
|
49
|
+
'qr-code': { repo: 'vue-components', files: ['vue-components:src/components/QrCode.vue'] },
|
|
50
|
+
'select-bar': { repo: 'vue-components', files: ['vue-components:src/components/SelectBar.vue'] },
|
|
51
|
+
'slider-toggle': { repo: 'vue-components', files: ['vue-components:src/components/SliderToggle.vue'] },
|
|
52
|
+
'small-page': { repo: 'vue-components', files: ['vue-components:src/components/SmallPage.vue'] },
|
|
53
|
+
'timer': { repo: 'vue-components', files: ['vue-components:src/components/Timer.vue'] },
|
|
54
|
+
'tooltip': { repo: 'vue-components', files: ['vue-components:src/components/Tooltip.vue'] },
|
|
55
|
+
|
|
56
|
+
// --- CSS-framework components (upstream/nimiq-style) ---
|
|
57
|
+
'buttons': { repo: 'nimiq-style', files: ['nimiq-style:src/buttons.css', 'nimiq-style:nimiq-style.min.css'] },
|
|
58
|
+
'card': { repo: 'nimiq-style', files: ['nimiq-style:src/layout.css', 'nimiq-style:nimiq-style.min.css'] },
|
|
59
|
+
|
|
60
|
+
// --- wallet ports (upstream/wallet/src/components) ---
|
|
61
|
+
'account-header': { repo: 'wallet', files: ['wallet:src/components/layouts/AddressOverview.vue', 'wallet:src/components/SearchBar.vue', 'wallet:src/components/staking/StakingButton.vue', 'wallet:src/components/icons/Staking/StakingIcon.vue'] },
|
|
62
|
+
'backup-banner': { repo: 'wallet', files: ['wallet:src/components/layouts/AccountOverview.vue'] },
|
|
63
|
+
'balance-distribution': { repo: 'wallet', files: ['wallet:src/components/BalanceDistribution.vue'] },
|
|
64
|
+
'consensus-icon': { repo: 'wallet', files: ['wallet:src/components/ConsensusIcon.vue', 'wallet:src/components/icons/WorldCheckIcon.vue'] },
|
|
65
|
+
'price-chart': { repo: 'wallet', files: ['wallet:src/components/PriceChart.vue', 'wallet:src/components/LineChart.vue'] },
|
|
66
|
+
'search-bar': { repo: 'wallet', files: ['wallet:src/components/SearchBar.vue'] },
|
|
67
|
+
'swap-balance-bar': { repo: 'wallet', files: ['wallet:src/components/swap/SwapBalanceBar.vue'] },
|
|
68
|
+
'toast-notification': { repo: 'wallet', files: ['wallet:src/components/swap/SwapNotification.vue'] },
|
|
69
|
+
'transaction-list': { repo: 'wallet', files: ['wallet:src/components/TransactionListItem.vue', 'wallet:src/components/TransactionList.vue'] },
|
|
70
|
+
|
|
71
|
+
// --- hub ---
|
|
72
|
+
'status-screen': { repo: 'hub', files: ['hub:src/components/StatusScreen.vue'] },
|
|
73
|
+
|
|
74
|
+
// --- nimiq-ui (docs theme) ---
|
|
75
|
+
'status-alert': { repo: 'nimiq-ui', files: ['nimiq-ui:packages/nimiq-vitepress-theme/src/assets/github-callouts.css'] },
|
|
76
|
+
|
|
77
|
+
// --- marketing: calibrated to nimiq.com (no vendored git repo) ---
|
|
78
|
+
'app-showcase-card': { repo: 'nimiq-com', files: [] },
|
|
79
|
+
'hero-section': { repo: 'nimiq-com', files: [] },
|
|
80
|
+
'honeycomb-band': { repo: 'nimiq-com', files: [] },
|
|
81
|
+
|
|
82
|
+
// --- original brand composition ---
|
|
83
|
+
'flag-hex': { repo: 'original', files: [] },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const VIRTUAL = new Set(['original', 'nimiq-com']); // not vendored git repos
|
|
87
|
+
|
|
88
|
+
function shortHead(repo) {
|
|
89
|
+
if (VIRTUAL.has(repo)) return null;
|
|
90
|
+
try {
|
|
91
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
92
|
+
cwd: join(ROOT, 'upstream', repo), encoding: 'utf8',
|
|
93
|
+
}).trim();
|
|
94
|
+
} catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fileExists(spec) {
|
|
98
|
+
const [repo, ...rest] = spec.split(':');
|
|
99
|
+
const path = rest.join(':');
|
|
100
|
+
if (VIRTUAL.has(repo)) return true;
|
|
101
|
+
return existsSync(join(ROOT, 'upstream', repo, path));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { readdir } = await import('node:fs/promises');
|
|
105
|
+
const names = (await readdir(COMPONENTS)).sort();
|
|
106
|
+
let missing = [], unresolved = [], wrote = 0;
|
|
107
|
+
|
|
108
|
+
for (const name of names) {
|
|
109
|
+
const entry = MAP[name];
|
|
110
|
+
if (!entry) { missing.push(name); continue; }
|
|
111
|
+
for (const f of entry.files) if (!fileExists(f)) unresolved.push(`${name} → ${f}`);
|
|
112
|
+
|
|
113
|
+
const metaPath = join(COMPONENTS, name, 'meta.json');
|
|
114
|
+
const meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
115
|
+
meta.source = { repo: entry.repo, ref: shortHead(entry.repo), files: entry.files };
|
|
116
|
+
if (!CHECK) {
|
|
117
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
118
|
+
wrote++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`components: ${names.length}, mapped: ${names.length - missing.length}, ${CHECK ? 'checked' : `wrote ${wrote}`}`);
|
|
123
|
+
if (missing.length) console.log(`UNMAPPED (no MAP entry): ${missing.join(', ')}`);
|
|
124
|
+
if (unresolved.length) { console.log(`UNRESOLVED upstream paths:`); unresolved.forEach(u => console.log(' ✗ ' + u)); }
|
|
125
|
+
else console.log('all upstream paths resolve ✓');
|
|
126
|
+
process.exitCode = (missing.length || unresolved.length) ? 1 : 0;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// nq hooks install — drift hooks that keep an app on the canonical stack.
|
|
2
|
+
//
|
|
3
|
+
// git pre-commit → `nq align --fail-on=settlement,styling` (blocks a commit that
|
|
4
|
+
// introduces the broken light-client path or off-brand styling)
|
|
5
|
+
// SessionStart → `nq align --quiet` advisory drift banner (one line)
|
|
6
|
+
// weekly GH Action → `nq align --all` → PR safe fixes / file a rolling drift issue,
|
|
7
|
+
// reusing the existing nq audit weekly machinery.
|
|
8
|
+
//
|
|
9
|
+
// `nq hooks install [target]` writes the git pre-commit hook into the target repo (cwd
|
|
10
|
+
// by default) and prints the SessionStart + workflow snippets (with --write to drop the
|
|
11
|
+
// workflow file too). Artifacts also live under hooks/ in this repo for copy/paste.
|
|
12
|
+
import { mkdir, writeFile, chmod, readFile, copyFile } from 'node:fs/promises';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const ROOT = resolve(HERE, '..');
|
|
19
|
+
|
|
20
|
+
export const PRE_COMMIT = `#!/usr/bin/env bash
|
|
21
|
+
# nq align pre-commit gate — installed by \`nq hooks install\`.
|
|
22
|
+
# Blocks a commit that introduces the broken @nimiq/core light-client path
|
|
23
|
+
# (Client.create / @nimiq/core/web / waitForConsensusEstablished) or off-brand styling.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
if command -v nq >/dev/null 2>&1; then
|
|
26
|
+
nq align --fail-on=settlement,styling
|
|
27
|
+
else
|
|
28
|
+
npx -y github:Andjroo111/nimiq-branding-cli align --fail-on=settlement,styling
|
|
29
|
+
fi
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
export const SESSION_START = `# nq align — SessionStart advisory drift banner.
|
|
33
|
+
# Add to .claude/settings.json hooks.SessionStart, or run at the top of a session.
|
|
34
|
+
nq align --quiet 2>/dev/null || true
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const WEEKLY_WORKFLOW = `name: stack-align
|
|
38
|
+
|
|
39
|
+
# Weekly fleet stack-alignment audit — the sibling of branding-audit.yml. Runs
|
|
40
|
+
# \`nq align --all\` across the repo, PRs safe manifest fixes (nq align --fix), and
|
|
41
|
+
# files/updates a rolling stack-drift issue on any risky-fail (the broken light-client
|
|
42
|
+
# path, off-brand styling). Reuses the same verdict-driven shape as audit.yml.
|
|
43
|
+
on:
|
|
44
|
+
schedule:
|
|
45
|
+
- cron: '30 9 * * 1' # Mondays 09:30 UTC (after branding-audit at 09:00)
|
|
46
|
+
workflow_dispatch:
|
|
47
|
+
|
|
48
|
+
permissions:
|
|
49
|
+
contents: write
|
|
50
|
+
pull-requests: write
|
|
51
|
+
issues: write
|
|
52
|
+
|
|
53
|
+
concurrency:
|
|
54
|
+
group: stack-align
|
|
55
|
+
cancel-in-progress: false
|
|
56
|
+
|
|
57
|
+
jobs:
|
|
58
|
+
align:
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
timeout-minutes: 15
|
|
61
|
+
env:
|
|
62
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
63
|
+
steps:
|
|
64
|
+
- uses: actions/checkout@v4
|
|
65
|
+
- uses: actions/setup-node@v4
|
|
66
|
+
with:
|
|
67
|
+
node-version: 20
|
|
68
|
+
|
|
69
|
+
- name: Run nq align
|
|
70
|
+
id: align
|
|
71
|
+
run: |
|
|
72
|
+
npx -y github:Andjroo111/nimiq-branding-cli align --all . --json > align.json || true
|
|
73
|
+
node -e "const r=require('./align.json').results.filter(x=>!x.exempt); const bad=r.filter(x=>x.overall==='risky-fail'); const drift=r.filter(x=>x.overall==='safe-drift'); console.log('risky='+bad.length); console.log('drift='+drift.length); process.stdout.write('');"
|
|
74
|
+
risky=\$(node -e "console.log(require('./align.json').results.filter(x=>!x.exempt&&x.overall==='risky-fail').length)")
|
|
75
|
+
drift=\$(node -e "console.log(require('./align.json').results.filter(x=>!x.exempt&&x.overall==='safe-drift').length)")
|
|
76
|
+
echo "risky=\$risky" >> "\$GITHUB_OUTPUT"
|
|
77
|
+
echo "drift=\$drift" >> "\$GITHUB_OUTPUT"
|
|
78
|
+
|
|
79
|
+
# ---- SAFE: only manifest drift → autofix the manifests + open a PR ----
|
|
80
|
+
- name: PR safe manifest fixes
|
|
81
|
+
if: steps.align.outputs.risky == '0' && steps.align.outputs.drift != '0'
|
|
82
|
+
run: |
|
|
83
|
+
npx -y github:Andjroo111/nimiq-branding-cli align --all . --fix || true
|
|
84
|
+
git config user.name "nq-align-bot"
|
|
85
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
86
|
+
branch="align/auto-manifest-fix"
|
|
87
|
+
git checkout -B "\$branch"
|
|
88
|
+
git add -A '*nimiq-stack.json'
|
|
89
|
+
if git diff --cached --quiet; then echo "nothing to fix"; exit 0; fi
|
|
90
|
+
git commit -m "align: safe manifest fixes (nq align --fix)"
|
|
91
|
+
git push -f origin "\$branch"
|
|
92
|
+
if [ "\$(gh pr list --head "\$branch" --state open --json number --jq 'length')" = "0" ]; then
|
|
93
|
+
gh pr create --head "\$branch" --base main \\
|
|
94
|
+
--title "align: safe manifest fixes" \\
|
|
95
|
+
--body "Automated \\\`nq align --fix\\\` — manifest-only, no source changes."
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
# ---- RISKY: a load-bearing axis failed (light client / off-brand) → rolling issue ----
|
|
99
|
+
- name: Open/update stack-drift issue
|
|
100
|
+
if: steps.align.outputs.risky != '0'
|
|
101
|
+
run: |
|
|
102
|
+
gh label create stack-drift --color B91C1C \\
|
|
103
|
+
--description "an app drifted off the canonical Nimiq fleet stack" 2>/dev/null || true
|
|
104
|
+
body="$(npx -y github:Andjroo111/nimiq-branding-cli align --all . 2>&1 || true)"
|
|
105
|
+
existing=\$(gh issue list --state open --label stack-drift --json number --jq '.[0].number')
|
|
106
|
+
if [ -n "\$existing" ]; then
|
|
107
|
+
printf '%s\\n' "\$body" | gh issue comment "\$existing" --body-file -
|
|
108
|
+
else
|
|
109
|
+
printf '%s\\n' "\$body" | gh issue create --title "⚙️ Nimiq stack drift needs review" \\
|
|
110
|
+
--label stack-drift --body-file -
|
|
111
|
+
fi
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
export async function installHooks(target, opts = {}) {
|
|
115
|
+
const repo = resolve(target ?? '.');
|
|
116
|
+
const out = { wrote: [], printed: [] };
|
|
117
|
+
|
|
118
|
+
// 1) git pre-commit
|
|
119
|
+
const gitDir = join(repo, '.git');
|
|
120
|
+
if (existsSync(gitDir)) {
|
|
121
|
+
const hookDir = join(gitDir, 'hooks');
|
|
122
|
+
await mkdir(hookDir, { recursive: true });
|
|
123
|
+
const hookPath = join(hookDir, 'pre-commit');
|
|
124
|
+
await writeFile(hookPath, PRE_COMMIT);
|
|
125
|
+
await chmod(hookPath, 0o755);
|
|
126
|
+
out.wrote.push(hookPath);
|
|
127
|
+
} else {
|
|
128
|
+
out.printed.push(`(no .git in ${repo} — skipped pre-commit; init git then re-run)`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2) weekly workflow (write only with --write)
|
|
132
|
+
if (opts.write) {
|
|
133
|
+
const wfDir = join(repo, '.github', 'workflows');
|
|
134
|
+
await mkdir(wfDir, { recursive: true });
|
|
135
|
+
const wfPath = join(wfDir, 'stack-align.yml');
|
|
136
|
+
await writeFile(wfPath, WEEKLY_WORKFLOW);
|
|
137
|
+
out.wrote.push(wfPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// write the committed artifacts under hooks/ in THIS repo (used by build/CI, not the app)
|
|
144
|
+
export async function emitArtifacts() {
|
|
145
|
+
const dir = join(ROOT, 'hooks');
|
|
146
|
+
await mkdir(dir, { recursive: true });
|
|
147
|
+
await writeFile(join(dir, 'pre-commit'), PRE_COMMIT);
|
|
148
|
+
await chmod(join(dir, 'pre-commit'), 0o755);
|
|
149
|
+
await writeFile(join(dir, 'session-start.sh'), SESSION_START);
|
|
150
|
+
await writeFile(join(dir, 'stack-align.yml'), WEEKLY_WORKFLOW);
|
|
151
|
+
return dir;
|
|
152
|
+
}
|