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,435 @@
|
|
|
1
|
+
// nq align — fleet stack-alignment grader.
|
|
2
|
+
//
|
|
3
|
+
// Branding accuracy (nq audit / nq verify) asks "does our UI match Nimiq's design?".
|
|
4
|
+
// nq align asks the sibling question for a whole APP: "is this app on the canonical
|
|
5
|
+
// Nimiq fleet stack?" — same self-learning vocabulary (clean / safe-drift / risky-fail).
|
|
6
|
+
//
|
|
7
|
+
// The load-bearing axis is SETTLEMENT. The @nimiq/core light client is broken on our
|
|
8
|
+
// hosts (never reaches consensus). So an `@nimiq/core/web` import, a `Client.create(`,
|
|
9
|
+
// or a `waitForConsensusEstablished(` in src is a HARD FAIL — chain reads must go
|
|
10
|
+
// through the rpc-block-scan path (the nimiq-settlement package).
|
|
11
|
+
//
|
|
12
|
+
// Reads <app>/nimiq-stack.json (infers + offers to write one if absent), grades each
|
|
13
|
+
// axis against align/canonical.json, prints a verdict table, and exits nonzero when a
|
|
14
|
+
// --fail-on axis fails (the pre-commit / CI gate).
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// nq align [path] grade one app (default: cwd)
|
|
18
|
+
// nq align --all <dir> grade every app dir under <dir>
|
|
19
|
+
// nq align --fix safe autofixes only (write/repair the manifest)
|
|
20
|
+
// nq align --fail-on=settlement,styling exit 1 if any listed axis is risky-fail
|
|
21
|
+
// nq align --quiet one-line drift banner (for SessionStart)
|
|
22
|
+
// nq align --json machine-readable
|
|
23
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { dirname, join, resolve, basename } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const ROOT = resolve(HERE, '..');
|
|
30
|
+
|
|
31
|
+
// ---- verdict vocabulary (shared with nq audit) ----
|
|
32
|
+
export const CLEAN = 'clean';
|
|
33
|
+
export const SAFE = 'safe-drift';
|
|
34
|
+
export const RISKY = 'risky-fail';
|
|
35
|
+
const RANK = { [CLEAN]: 0, [SAFE]: 1, [RISKY]: 2 };
|
|
36
|
+
const worst = (a, b) => (RANK[a] >= RANK[b] ? a : b);
|
|
37
|
+
|
|
38
|
+
// axes that may be named in --fail-on
|
|
39
|
+
const AXES = ['settlement', 'styling', 'stack', 'deploy', 'config'];
|
|
40
|
+
|
|
41
|
+
const MANIFEST = 'nimiq-stack.json';
|
|
42
|
+
|
|
43
|
+
export async function loadCanonical() {
|
|
44
|
+
return JSON.parse(await readFile(join(ROOT, 'align', 'canonical.json'), 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readJson(path) {
|
|
48
|
+
try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- source scan for the broken light-client path ----
|
|
52
|
+
// Walks the app's src/ (+ a couple of common roots) for any forbidden import/call.
|
|
53
|
+
// Returns the list of "file:line snippet" hits. This is what makes SETTLEMENT load-bearing.
|
|
54
|
+
const SRC_DIRS = ['src', 'app', 'lib', 'server', 'public', 'functions'];
|
|
55
|
+
const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts|vue|svelte)$/;
|
|
56
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.output', 'coverage', '.verify', '.audit']);
|
|
57
|
+
|
|
58
|
+
async function scanLightClient(appDir, canonical) {
|
|
59
|
+
const forbiddenImports = canonical.lightClient?.forbiddenImports ?? ['@nimiq/core/web'];
|
|
60
|
+
const forbiddenCalls = canonical.lightClient?.forbiddenCalls ?? ['Client.create(', 'waitForConsensusEstablished('];
|
|
61
|
+
const hits = [];
|
|
62
|
+
const coreImports = []; // files that import @nimiq/core (any subpath) — for coreRole check
|
|
63
|
+
|
|
64
|
+
async function walk(dir) {
|
|
65
|
+
let entries;
|
|
66
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
67
|
+
for (const e of entries) {
|
|
68
|
+
if (e.name.startsWith('.') && e.name !== '.well-known') continue;
|
|
69
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
70
|
+
const full = join(dir, e.name);
|
|
71
|
+
if (e.isDirectory()) { await walk(full); continue; }
|
|
72
|
+
if (!CODE_EXT.test(e.name)) continue;
|
|
73
|
+
let text;
|
|
74
|
+
try { text = await readFile(full, 'utf8'); } catch { continue; }
|
|
75
|
+
const rel = full.slice(appDir.length + 1);
|
|
76
|
+
const lines = text.split('\n');
|
|
77
|
+
for (let i = 0; i < lines.length; i++) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
// a pure line-comment can mention these strings ("don't call Client.create()")
|
|
81
|
+
// without being a real usage — skip those to avoid false positives.
|
|
82
|
+
const isComment = trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
|
|
83
|
+
for (const imp of forbiddenImports) {
|
|
84
|
+
// a comment can quote the forbidden import without being a real usage; only a
|
|
85
|
+
// non-comment line that contains it is a real import (imports never start with //).
|
|
86
|
+
if (!isComment && line.includes(imp)) {
|
|
87
|
+
hits.push({ file: rel, line: i + 1, kind: 'import', match: imp, text: trimmed.slice(0, 100) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!isComment) {
|
|
91
|
+
for (const call of forbiddenCalls) {
|
|
92
|
+
if (line.includes(call)) hits.push({ file: rel, line: i + 1, kind: 'call', match: call, text: trimmed.slice(0, 100) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (/from\s+['"]@nimiq\/core/.test(line) || /require\(\s*['"]@nimiq\/core/.test(line)) {
|
|
96
|
+
coreImports.push({ file: rel, line: i + 1, text: line.trim().slice(0, 100) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const d of SRC_DIRS) {
|
|
102
|
+
const p = join(appDir, d);
|
|
103
|
+
if (existsSync(p)) await walk(p);
|
|
104
|
+
}
|
|
105
|
+
return { hits, coreImports };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- manifest inference (when absent) ----
|
|
109
|
+
async function inferManifest(appDir) {
|
|
110
|
+
const pkg = await readJson(join(appDir, 'package.json')) ?? {};
|
|
111
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
112
|
+
const has = (n) => n in deps;
|
|
113
|
+
const fileExists = (rel) => existsSync(join(appDir, rel));
|
|
114
|
+
|
|
115
|
+
const runtime = fileExists('bun.lock') || fileExists('bun.lockb') ? 'bun'
|
|
116
|
+
: has('next') ? 'edge' : 'node';
|
|
117
|
+
const packageManager = fileExists('bun.lock') || fileExists('bun.lockb') ? 'bun'
|
|
118
|
+
: fileExists('pnpm-lock.yaml') ? 'pnpm'
|
|
119
|
+
: fileExists('yarn.lock') ? 'yarn' : 'npm';
|
|
120
|
+
const framework = has('next') ? 'next' : has('nuxt') ? 'nuxt'
|
|
121
|
+
: has('vue') ? 'vue' : 'vanilla-pwa';
|
|
122
|
+
const server = has('hono') ? `hono@${deps.hono ?? '^4'}`
|
|
123
|
+
: has('next') ? 'next' : has('express') ? 'express' : 'none';
|
|
124
|
+
const build = has('vite') ? 'vite' : has('next') ? 'next' : has('esbuild') ? 'esbuild' : 'none';
|
|
125
|
+
|
|
126
|
+
const stylingSource = (has('@nimiq/style') || has('nimiq-css') || fileExists('public/css/tokens.css'))
|
|
127
|
+
? 'nimiq-ui' : 'unknown';
|
|
128
|
+
|
|
129
|
+
const settlementLib = has('nimiq-settlement') ? 'nimiq-settlement'
|
|
130
|
+
: (fileExists('src/lib/chain.ts') || fileExists('src/payments/nimiq-rpc-client.ts')) ? 'inline'
|
|
131
|
+
: 'none';
|
|
132
|
+
const coreRole = has('@nimiq/core') ? 'offline-crypto-only' : 'none';
|
|
133
|
+
|
|
134
|
+
const target = fileExists('fly.toml') ? 'fly'
|
|
135
|
+
: fileExists('netlify.toml') ? 'netlify'
|
|
136
|
+
: fileExists('wrangler.toml') ? 'cloudflare-pages' : 'unknown';
|
|
137
|
+
const ci = existsSync(join(appDir, '.github', 'workflows'));
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
$schema: 'https://nimiq.tech/schemas/nimiq-stack.v1.json',
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : basename(appDir),
|
|
143
|
+
chainApp: has('@nimiq/core') || settlementLib !== 'none',
|
|
144
|
+
exempt: false,
|
|
145
|
+
exemptReason: null,
|
|
146
|
+
stack: { framework, server, runtime, build, packageManager },
|
|
147
|
+
styling: { source: stylingSource, notes: 'inferred by nq align --fix; confirm' },
|
|
148
|
+
settlement: {
|
|
149
|
+
pattern: settlementLib === 'none' ? 'mock' : 'rpc-block-scan',
|
|
150
|
+
lib: settlementLib,
|
|
151
|
+
coreRole,
|
|
152
|
+
notes: 'inferred by nq align --fix; confirm',
|
|
153
|
+
},
|
|
154
|
+
deploy: {
|
|
155
|
+
target, region: null, storage: null, autoDeploy: ci ? 'github-actions' : null,
|
|
156
|
+
edge: { provider: null, proxied: null, access: null },
|
|
157
|
+
},
|
|
158
|
+
config: {
|
|
159
|
+
tsconfig: fileExists('tsconfig.json') ? 'local-strict' : 'none',
|
|
160
|
+
lint: has('eslint') ? 'eslint' : has('@biomejs/biome') ? 'biome' : 'none',
|
|
161
|
+
fileSizeGuard: false,
|
|
162
|
+
ci,
|
|
163
|
+
},
|
|
164
|
+
canonicalVersion: '0.0.0',
|
|
165
|
+
_inferred: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- the axis graders ----
|
|
170
|
+
// Each returns { verdict, lines: [..notes] }.
|
|
171
|
+
|
|
172
|
+
function gradeSettlement(m, canonical, scan) {
|
|
173
|
+
const lines = [];
|
|
174
|
+
let v = CLEAN;
|
|
175
|
+
// (1) HARD FAIL: the broken light-client path anywhere in src.
|
|
176
|
+
if (scan.hits.length) {
|
|
177
|
+
v = RISKY;
|
|
178
|
+
for (const h of scan.hits.slice(0, 6)) {
|
|
179
|
+
lines.push(`HARD FAIL: ${h.kind} ${h.match} (${h.file}:${h.line})`);
|
|
180
|
+
}
|
|
181
|
+
if (scan.hits.length > 6) lines.push(` ...and ${scan.hits.length - 6} more`);
|
|
182
|
+
lines.push(' the @nimiq/core light client never reaches consensus on our hosts — use rpc-block-scan via nimiq-settlement');
|
|
183
|
+
return { verdict: v, lines };
|
|
184
|
+
}
|
|
185
|
+
// (2) declared pattern must not be the forbidden one
|
|
186
|
+
const forbidden = canonical.settlement.forbiddenPatterns ?? ['light-client'];
|
|
187
|
+
if (forbidden.includes(m.settlement?.pattern)) {
|
|
188
|
+
return { verdict: RISKY, lines: [`settlement.pattern "${m.settlement.pattern}" is forbidden (light client is broken on our hosts)`] };
|
|
189
|
+
}
|
|
190
|
+
const accepted = canonical.settlement.acceptedPatterns ?? ['rpc-block-scan', 'mock', 'noop'];
|
|
191
|
+
if (!accepted.includes(m.settlement?.pattern)) {
|
|
192
|
+
v = worst(v, SAFE);
|
|
193
|
+
lines.push(`settlement.pattern "${m.settlement?.pattern}" not in canonical set [${accepted.join(', ')}]`);
|
|
194
|
+
}
|
|
195
|
+
// (3) chain reads via the settlement package (warn until extracted)
|
|
196
|
+
const acceptedLibs = canonical.settlement.acceptedLibs ?? ['nimiq-settlement', 'inline'];
|
|
197
|
+
if (!acceptedLibs.includes(m.settlement?.lib)) {
|
|
198
|
+
v = worst(v, SAFE);
|
|
199
|
+
lines.push(`settlement.lib "${m.settlement?.lib}" not canonical — chain reads should use the ${canonical.settlementPackage} package`);
|
|
200
|
+
} else if (m.settlement?.lib === 'inline') {
|
|
201
|
+
lines.push(`settlement.lib inline — fine for now; migrate to the shared ${canonical.settlementPackage} package once extracted`);
|
|
202
|
+
}
|
|
203
|
+
// (4) coreRole must match actual @nimiq/core usage
|
|
204
|
+
if (scan.coreImports.length && m.settlement?.coreRole !== 'offline-crypto-only') {
|
|
205
|
+
v = worst(v, SAFE);
|
|
206
|
+
lines.push(`@nimiq/core is imported but coreRole is "${m.settlement?.coreRole}" — should be "offline-crypto-only"`);
|
|
207
|
+
}
|
|
208
|
+
if (!scan.coreImports.length && m.settlement?.coreRole === 'offline-crypto-only') {
|
|
209
|
+
lines.push('coreRole offline-crypto-only but no @nimiq/core import found (declared-only; OK)');
|
|
210
|
+
}
|
|
211
|
+
if (v === CLEAN && !lines.length) lines.push('rpc-block-scan / offline-crypto-only — no light-client path in src');
|
|
212
|
+
return { verdict: v, lines };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function gradeStyling(m, canonical) {
|
|
216
|
+
if (!m.chainApp) return { verdict: CLEAN, lines: ['not a chain app — styling parity not enforced'] };
|
|
217
|
+
if (m.styling?.source === canonical.styling.source) {
|
|
218
|
+
return { verdict: CLEAN, lines: [`styling.source ${canonical.styling.source} (@nimiq/style / nq)`] };
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
verdict: RISKY,
|
|
222
|
+
lines: [`chainApp styling.source "${m.styling?.source}" != "${canonical.styling.source}" — use @nimiq/style + nq components (nq init / nq add)`],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function gradeStack(m, canonical, scan) {
|
|
227
|
+
const lines = [];
|
|
228
|
+
let v = CLEAN;
|
|
229
|
+
const c = canonical.stack;
|
|
230
|
+
const s = m.stack ?? {};
|
|
231
|
+
const norm = (x) => String(x ?? '').replace(/@.*$/, '');
|
|
232
|
+
for (const key of ['framework', 'runtime', 'packageManager']) {
|
|
233
|
+
if (norm(s[key]) !== norm(c[key])) {
|
|
234
|
+
v = worst(v, SAFE);
|
|
235
|
+
lines.push(`stack.${key} "${s[key]}" drifts from canonical "${c[key]}"`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// a bundler is a drift UNLESS a /web import justifies it
|
|
239
|
+
if (norm(s.build) !== norm(c.build)) {
|
|
240
|
+
const justified = scan.coreImports.some(h => h.text.includes('@nimiq/core/web'));
|
|
241
|
+
if (justified) lines.push(`stack.build "${s.build}" (bundler) — justified by an @nimiq/core/web import`);
|
|
242
|
+
else { v = worst(v, SAFE); lines.push(`stack.build "${s.build}" != canonical "${c.build}" (no /web import to justify a bundler)`); }
|
|
243
|
+
}
|
|
244
|
+
if (v === CLEAN && !lines.length) lines.push(`${c.framework} / ${c.runtime} / ${c.packageManager} — canonical`);
|
|
245
|
+
return { verdict: v, lines };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function gradeDeploy(m, canonical, appDir) {
|
|
249
|
+
const lines = [];
|
|
250
|
+
let v = CLEAN;
|
|
251
|
+
const target = m.deploy?.target;
|
|
252
|
+
if (!target || target === 'unknown') {
|
|
253
|
+
v = worst(v, SAFE);
|
|
254
|
+
lines.push('deploy.target unknown — wire a real target (Fly: Dockerfile + fly.toml)');
|
|
255
|
+
}
|
|
256
|
+
// verify the deploy kit is actually present when target is fly
|
|
257
|
+
if (target === 'fly') {
|
|
258
|
+
const missing = ['Dockerfile', 'fly.toml'].filter(f => !existsSync(join(appDir, f)));
|
|
259
|
+
if (missing.length) { v = worst(v, SAFE); lines.push(`deploy.target fly but missing: ${missing.join(', ')}`); }
|
|
260
|
+
}
|
|
261
|
+
if (!m.config?.ci) { v = worst(v, SAFE); lines.push('no CI declared (config.ci=false) — deploy should be CI-gated'); }
|
|
262
|
+
if (v === CLEAN && !lines.length) lines.push(`${target} + CI wired`);
|
|
263
|
+
return { verdict: v, lines };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function gradeConfig(m, canonical) {
|
|
267
|
+
const lines = [];
|
|
268
|
+
let v = CLEAN;
|
|
269
|
+
const c = canonical.config;
|
|
270
|
+
if (m.config?.tsconfig !== c.tsconfig) { v = worst(v, SAFE); lines.push(`config.tsconfig "${m.config?.tsconfig}" != "${c.tsconfig}"`); }
|
|
271
|
+
if (m.config?.fileSizeGuard !== c.fileSizeGuard && m.config?.fileSizeGuard !== false) {
|
|
272
|
+
v = worst(v, SAFE); lines.push(`config.fileSizeGuard ${m.config?.fileSizeGuard} != canonical ${c.fileSizeGuard}`);
|
|
273
|
+
} else if (m.config?.fileSizeGuard === false) {
|
|
274
|
+
v = worst(v, SAFE); lines.push(`config.fileSizeGuard off — canonical guards source files at ${c.fileSizeGuard} lines`);
|
|
275
|
+
}
|
|
276
|
+
if (m.canonicalVersion && canonical.canonicalVersion && m.canonicalVersion !== canonical.canonicalVersion) {
|
|
277
|
+
lines.push(`canonicalVersion ${m.canonicalVersion} != fleet ${canonical.canonicalVersion} (re-align)`);
|
|
278
|
+
}
|
|
279
|
+
if (v === CLEAN && !lines.length) lines.push('tsconfig / fileSizeGuard / ci parity');
|
|
280
|
+
return { verdict: v, lines };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- grade one app ----
|
|
284
|
+
export async function alignApp(appDir, opts = {}) {
|
|
285
|
+
const canonical = await loadCanonical();
|
|
286
|
+
const manifestPath = join(appDir, MANIFEST);
|
|
287
|
+
let m = await readJson(manifestPath);
|
|
288
|
+
let inferred = false;
|
|
289
|
+
let wrote = false;
|
|
290
|
+
|
|
291
|
+
if (!m) {
|
|
292
|
+
m = await inferManifest(appDir);
|
|
293
|
+
inferred = true;
|
|
294
|
+
if (opts.fix) { await writeFile(manifestPath, JSON.stringify(stripInternal(m), null, 2) + '\n'); wrote = true; }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// EXEMPT: report, never fail.
|
|
298
|
+
if (m.exempt) {
|
|
299
|
+
return {
|
|
300
|
+
app: m.name ?? basename(appDir), appDir, exempt: true,
|
|
301
|
+
reason: m.exemptReason ?? 'exempt:true', overall: CLEAN, axes: {}, inferred, wrote,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const scan = await scanLightClient(appDir, canonical);
|
|
306
|
+
|
|
307
|
+
const axes = {
|
|
308
|
+
settlement: m.chainApp ? gradeSettlement(m, canonical, scan) : { verdict: CLEAN, lines: ['--no-chain: settlement parity skipped'] },
|
|
309
|
+
styling: gradeStyling(m, canonical),
|
|
310
|
+
stack: gradeStack(m, canonical, scan),
|
|
311
|
+
deploy: gradeDeploy(m, canonical, appDir),
|
|
312
|
+
config: gradeConfig(m, canonical),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// --fix: repair the manifest in place (safe writes only — never touches source).
|
|
316
|
+
if (opts.fix && !inferred) {
|
|
317
|
+
const repaired = repairManifest(m, canonical);
|
|
318
|
+
if (repaired) {
|
|
319
|
+
await writeFile(manifestPath, JSON.stringify(stripInternal(repaired), null, 2) + '\n');
|
|
320
|
+
wrote = true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let overall = CLEAN;
|
|
325
|
+
for (const a of Object.values(axes)) overall = worst(overall, a.verdict);
|
|
326
|
+
|
|
327
|
+
return { app: m.name ?? basename(appDir), appDir, exempt: false, overall, axes, inferred, wrote, scanHits: scan.hits.length };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function stripInternal(m) {
|
|
331
|
+
const { _inferred, ...rest } = m;
|
|
332
|
+
return rest;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// safe autofixes: bump canonicalVersion + backfill missing/null config knobs.
|
|
336
|
+
// NEVER changes source files or load-bearing settlement values.
|
|
337
|
+
function repairManifest(m, canonical) {
|
|
338
|
+
let changed = false;
|
|
339
|
+
const out = JSON.parse(JSON.stringify(m));
|
|
340
|
+
if (out.canonicalVersion !== canonical.canonicalVersion) { out.canonicalVersion = canonical.canonicalVersion; changed = true; }
|
|
341
|
+
out.config ??= {};
|
|
342
|
+
if (out.config.fileSizeGuard == null) { out.config.fileSizeGuard = canonical.config.fileSizeGuard; changed = true; }
|
|
343
|
+
if (out.config.tsconfig == null) { out.config.tsconfig = canonical.config.tsconfig; changed = true; }
|
|
344
|
+
if (out.exempt == null) { out.exempt = false; changed = true; }
|
|
345
|
+
if (out.exemptReason === undefined) { out.exemptReason = null; changed = true; }
|
|
346
|
+
if (!out.$schema) { out.$schema = 'https://nimiq.tech/schemas/nimiq-stack.v1.json'; changed = true; }
|
|
347
|
+
return changed ? out : null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- discovery for --all ----
|
|
351
|
+
async function findApps(dir) {
|
|
352
|
+
const out = [];
|
|
353
|
+
let entries;
|
|
354
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
|
|
355
|
+
// an app dir = has a package.json OR a nimiq-stack.json
|
|
356
|
+
if (existsSync(join(dir, MANIFEST)) || existsSync(join(dir, 'package.json'))) out.push(dir);
|
|
357
|
+
for (const e of entries) {
|
|
358
|
+
if (!e.isDirectory() || SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
|
359
|
+
const sub = join(dir, e.name);
|
|
360
|
+
if (existsSync(join(sub, MANIFEST)) || existsSync(join(sub, 'package.json'))) out.push(sub);
|
|
361
|
+
}
|
|
362
|
+
return [...new Set(out)];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---- rendering ----
|
|
366
|
+
const GLYPH = { [CLEAN]: '✓', [SAFE]: '~', [RISKY]: '✗' };
|
|
367
|
+
|
|
368
|
+
function renderApp(r, { quiet } = {}) {
|
|
369
|
+
if (r.exempt) return ` exempt ${r.app} (${r.reason})`;
|
|
370
|
+
const head = `${GLYPH[r.overall]} ${r.app} [${r.overall}]${r.inferred ? ' (inferred — run --fix to write nimiq-stack.json)' : ''}${r.wrote ? ' (manifest written/repaired)' : ''}`;
|
|
371
|
+
if (quiet) return head;
|
|
372
|
+
const rows = [head];
|
|
373
|
+
for (const axis of AXES) {
|
|
374
|
+
const a = r.axes[axis];
|
|
375
|
+
if (!a) continue;
|
|
376
|
+
rows.push(` ${axis.padEnd(11)} ${GLYPH[a.verdict]} ${a.verdict}`);
|
|
377
|
+
for (const l of a.lines) rows.push(` ${l}`);
|
|
378
|
+
}
|
|
379
|
+
return rows.join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---- entry ----
|
|
383
|
+
export async function run(rest, flags) {
|
|
384
|
+
const failOn = parseFailOn(flags);
|
|
385
|
+
const quiet = flags.quiet;
|
|
386
|
+
const targets = [];
|
|
387
|
+
|
|
388
|
+
if (flags.all) {
|
|
389
|
+
// --all <dir> (dir is the value after --all, or the first positional, or cwd)
|
|
390
|
+
const dir = resolve(typeof flags.all === 'string' ? flags.all : (rest[0] ?? '.'));
|
|
391
|
+
const apps = await findApps(dir);
|
|
392
|
+
if (!apps.length) throw new Error(`nq align --all: no app dirs under ${dir}`);
|
|
393
|
+
for (const a of apps) targets.push(a);
|
|
394
|
+
} else {
|
|
395
|
+
targets.push(resolve(rest[0] ?? '.'));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const results = [];
|
|
399
|
+
for (const appDir of targets) results.push(await alignApp(appDir, { fix: flags.fix }));
|
|
400
|
+
|
|
401
|
+
if (flags.json) {
|
|
402
|
+
console.log(JSON.stringify({ canonicalVersion: (await loadCanonical()).canonicalVersion, results }, null, 2));
|
|
403
|
+
} else if (quiet) {
|
|
404
|
+
const drift = results.filter(r => !r.exempt && r.overall !== CLEAN);
|
|
405
|
+
if (!drift.length) console.log(`nq align: ${results.length} app(s) aligned — clean.`);
|
|
406
|
+
else console.log(`nq align: ${drift.length}/${results.length} app(s) drifting — ${drift.map(r => `${r.app}[${r.overall}]`).join(' ')} · run \`nq align\` for detail`);
|
|
407
|
+
} else {
|
|
408
|
+
console.log(`\nnq align — canonical fleet baseline v${(await loadCanonical()).canonicalVersion}\n`);
|
|
409
|
+
for (const r of results) console.log(renderApp(r) + '\n');
|
|
410
|
+
const fails = results.filter(r => !r.exempt && r.overall === RISKY);
|
|
411
|
+
const drifts = results.filter(r => !r.exempt && r.overall === SAFE);
|
|
412
|
+
console.log(`${results.filter(r => !r.exempt && r.overall === CLEAN).length} clean · ${drifts.length} safe-drift · ${fails.length} risky-fail · ${results.filter(r => r.exempt).length} exempt`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// gate: exit nonzero if any --fail-on axis is risky-fail on any app
|
|
416
|
+
if (failOn.length) {
|
|
417
|
+
const gated = results.filter(r => !r.exempt).some(r =>
|
|
418
|
+
failOn.some(axis => r.axes[axis]?.verdict === RISKY));
|
|
419
|
+
if (gated) {
|
|
420
|
+
if (!flags.json) console.error(`\nnq align: GATE FAILED — a [${failOn.join(', ')}] axis is risky-fail.`);
|
|
421
|
+
process.exitCode = 1;
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
// default (no --fail-on): nonzero on any risky-fail, like nq audit's exit 2
|
|
425
|
+
if (results.filter(r => !r.exempt).some(r => r.overall === RISKY)) process.exitCode = 1;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return results;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function parseFailOn(flags) {
|
|
432
|
+
if (!flags.failOn) return [];
|
|
433
|
+
return String(flags.failOn).split(',').map(s => s.trim()).filter(Boolean)
|
|
434
|
+
.filter(a => { if (!AXES.includes(a)) throw new Error(`--fail-on: unknown axis "${a}" (axes: ${AXES.join(', ')})`); return true; });
|
|
435
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// apply-safe-bump — called by the weekly workflow ONLY when .audit/report.json
|
|
2
|
+
// verdict is "safe" (upstream moved, but every changed file is learnings-classified
|
|
3
|
+
// benign, no component/token touched, and pixel-verify passed). It fast-forwards the
|
|
4
|
+
// drifted repos' pins in upstream-pins.json to the live tips. The workflow then
|
|
5
|
+
// regenerates the skill block and opens a PR.
|
|
6
|
+
//
|
|
7
|
+
// Safe because the verdict gate already proved nothing visual changed.
|
|
8
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const report = JSON.parse(await readFile(join(ROOT, '.audit', 'report.json'), 'utf8'));
|
|
15
|
+
if (report.verdict !== 'safe') {
|
|
16
|
+
console.log(`apply-safe-bump: verdict is "${report.verdict}", not "safe" — refusing to bump.`);
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const pinsPath = join(ROOT, 'upstream-pins.json');
|
|
21
|
+
const pins = JSON.parse(await readFile(pinsPath, 'utf8'));
|
|
22
|
+
|
|
23
|
+
function commitDate(slug, sha) {
|
|
24
|
+
try {
|
|
25
|
+
const d = execFileSync('gh', ['api', `repos/${slug}/commits/${sha}`, '--jq', '.commit.committer.date'],
|
|
26
|
+
{ encoding: 'utf8' }).trim();
|
|
27
|
+
return d ? d.slice(0, 10) : null;
|
|
28
|
+
} catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
function slugOf(url) { const m = url.match(/github\.com[:/]+([^/]+\/[^/.]+)/); return m ? m[1] : null; }
|
|
31
|
+
|
|
32
|
+
const bumped = [];
|
|
33
|
+
for (const [repo, r] of Object.entries(report.repos)) {
|
|
34
|
+
if (!r.drifted || !r.liveFull) continue;
|
|
35
|
+
const info = pins.repos[repo];
|
|
36
|
+
if (!info) continue;
|
|
37
|
+
const date = commitDate(slugOf(info.url), r.liveFull) || new Date().toISOString().slice(0, 10);
|
|
38
|
+
info.pinned = r.liveFull;
|
|
39
|
+
info.pinnedDate = date;
|
|
40
|
+
bumped.push(`${repo} → ${r.liveFull.slice(0, 8)} (${date})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!bumped.length) { console.log('apply-safe-bump: nothing to bump.'); process.exit(0); }
|
|
44
|
+
await writeFile(pinsPath, JSON.stringify(pins, null, 2) + '\n');
|
|
45
|
+
console.log('apply-safe-bump: bumped\n ' + bumped.join('\n '));
|
|
46
|
+
// emit a one-line summary for the PR body / workflow output
|
|
47
|
+
console.log('::BUMP_SUMMARY::' + bumped.join('; '));
|