@notis_ai/cli 0.2.0-beta.156.1 → 0.2.0-beta.157.1
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/README.md +11 -45
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +5168 -7271
- package/dist/base-skills/notis-apps/SKILL.md +108 -194
- package/dist/base-skills/notis-cli/SKILL.md +64 -131
- package/package.json +1 -2
- package/skills/notis-apps/cli.md +34 -95
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/command-specs/apps.js +322 -1560
- package/src/runtime/agent-browser.js +169 -1
- package/src/runtime/app-boundary-validator.js +221 -0
- package/src/runtime/app-platform.js +359 -233
- package/src/runtime/app-test-server.js +292 -0
- package/template/app/page.tsx +45 -44
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +0 -1
- package/template/package.json +2 -2
- package/template/packages/sdk/package.json +1 -2
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +20 -7
- package/template/packages/sdk/src/config.ts +0 -2
- package/template/packages/sdk/src/interactions.ts +2 -1
- package/template/packages/sdk/src/styles.css +28 -1
- package/src/runtime/app-dev-build-supervisor.js +0 -47
- package/src/runtime/app-dev-build.js +0 -41
- package/src/runtime/app-dev-consumers.js +0 -154
- package/src/runtime/app-dev-host-lock.js +0 -80
- package/src/runtime/app-dev-process-identity.js +0 -111
- package/src/runtime/app-dev-roots.js +0 -284
- package/src/runtime/app-dev-server.js +0 -1136
- package/src/runtime/app-dev-sessions.js +0 -185
- package/src/runtime/cli-mode.generated.js +0 -5
- package/src/runtime/cli-mode.js +0 -34
|
@@ -227,6 +227,7 @@ export async function runHarnessRoute({
|
|
|
227
227
|
sessionName,
|
|
228
228
|
timeoutMs = 10_000,
|
|
229
229
|
snapshotPath = null,
|
|
230
|
+
designViewports = DEFAULT_DESIGN_VIEWPORTS,
|
|
230
231
|
}) {
|
|
231
232
|
const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
|
|
232
233
|
timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
|
|
@@ -283,14 +284,33 @@ export async function runHarnessRoute({
|
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
const timedOut = !harness.mounted && Date.now() >= deadline;
|
|
287
|
+
const runtimeCalls = Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [];
|
|
288
|
+
|
|
289
|
+
// Design assertions only mean something on a mounted route. Loading
|
|
290
|
+
// placeholders are excused while a runtime call is still in flight.
|
|
291
|
+
let design = [];
|
|
292
|
+
let designToolError = null;
|
|
293
|
+
if (harness.mounted && Array.isArray(designViewports) && designViewports.length > 0) {
|
|
294
|
+
await delay(500);
|
|
295
|
+
const pending = runtimeCalls.some((call) => call && call.ok == null);
|
|
296
|
+
const collected = await collectDesignFindings(sessionName, {
|
|
297
|
+
viewports: designViewports,
|
|
298
|
+
skipLoadingCheck: pending,
|
|
299
|
+
});
|
|
300
|
+
design = collected.findings;
|
|
301
|
+
designToolError = collected.tool_error;
|
|
302
|
+
}
|
|
303
|
+
|
|
286
304
|
return {
|
|
287
305
|
mounted: Boolean(harness.mounted),
|
|
288
306
|
renderStarted: Boolean(harness.renderStarted),
|
|
289
307
|
errors: Array.isArray(harness.errors) ? harness.errors : [],
|
|
290
|
-
runtimeCalls
|
|
308
|
+
runtimeCalls,
|
|
291
309
|
snapshotPath: savedSnapshotPath,
|
|
292
310
|
timed_out: timedOut,
|
|
293
311
|
tool_error: null,
|
|
312
|
+
design,
|
|
313
|
+
design_tool_error: designToolError,
|
|
294
314
|
raw: harness,
|
|
295
315
|
};
|
|
296
316
|
}
|
|
@@ -408,6 +428,8 @@ export async function captureHarnessScreenshot({
|
|
|
408
428
|
|
|
409
429
|
// Let the route settle (async data, resize transitions) before the capture.
|
|
410
430
|
await delay(500);
|
|
431
|
+
const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
|
|
432
|
+
const design = Array.isArray(designResult) ? designResult : [];
|
|
411
433
|
|
|
412
434
|
mkdirSync(dirname(screenshotPath), { recursive: true });
|
|
413
435
|
const screenshotArgs = ['--session', sessionName, 'screenshot'];
|
|
@@ -446,6 +468,7 @@ export async function captureHarnessScreenshot({
|
|
|
446
468
|
}
|
|
447
469
|
|
|
448
470
|
return {
|
|
471
|
+
design,
|
|
449
472
|
ok: true,
|
|
450
473
|
mounted: true,
|
|
451
474
|
screenshotPath,
|
|
@@ -462,3 +485,148 @@ export async function closeAgentBrowserSession(sessionName) {
|
|
|
462
485
|
});
|
|
463
486
|
return result.exitCode === 0;
|
|
464
487
|
}
|
|
488
|
+
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// Runtime design assertions.
|
|
491
|
+
//
|
|
492
|
+
// The static design lint (app-boundary-validator.js) catches banned class
|
|
493
|
+
// names. This script runs in the mounted harness page and catches what a
|
|
494
|
+
// regex cannot: a bordered box nested in a bordered box across files, tinted
|
|
495
|
+
// panels stacked three deep, text that computes below 12px, horizontal
|
|
496
|
+
// overflow at a phone width, and loading placeholders that outlive the data.
|
|
497
|
+
// Palette colors are deliberately not checked here: at runtime a token and a
|
|
498
|
+
// hardcoded hue resolve to the same RGB, so that stays with the static lint.
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
export const DESIGN_ASSERTIONS_MARKER = '__notisDesignAssertions';
|
|
501
|
+
|
|
502
|
+
export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
|
|
503
|
+
/* ${DESIGN_ASSERTIONS_MARKER} */
|
|
504
|
+
const root = document.getElementById('root') || document.body;
|
|
505
|
+
const out = [];
|
|
506
|
+
const seen = new Set();
|
|
507
|
+
const push = (kind, el, extra) => {
|
|
508
|
+
const cls = String(el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className || '').trim().slice(0, 80);
|
|
509
|
+
const text = (el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 60);
|
|
510
|
+
const snippet = el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\\s+/).slice(0, 6).join('.') : '') + (text ? ' "' + text + '"' : '');
|
|
511
|
+
const key = kind + '|' + snippet;
|
|
512
|
+
if (seen.has(key)) return;
|
|
513
|
+
seen.add(key);
|
|
514
|
+
out.push(Object.assign({ kind, snippet }, extra || {}));
|
|
515
|
+
};
|
|
516
|
+
const controls = new Set(['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'A', 'SUMMARY']);
|
|
517
|
+
const isControl = (el) => controls.has(el.tagName) || /^(button|checkbox|switch|radio|combobox|textbox|tab|menuitem)$/.test(el.getAttribute('role') || '');
|
|
518
|
+
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
519
|
+
const isBox = (el) => {
|
|
520
|
+
const cs = getComputedStyle(el);
|
|
521
|
+
if (!['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'].every((k) => parseFloat(cs[k]) > 0)) return false;
|
|
522
|
+
if (/rgba\\(\\d+, \\d+, \\d+, 0\\)|transparent/.test(cs.borderTopColor)) return false;
|
|
523
|
+
const r = el.getBoundingClientRect();
|
|
524
|
+
return r.width > 40 && r.height > 20;
|
|
525
|
+
};
|
|
526
|
+
const bg = (el) => getComputedStyle(el).backgroundColor;
|
|
527
|
+
const transparent = (c) => !c || c === 'transparent' || /rgba\\(\\d+, \\d+, \\d+, 0\\)/.test(c);
|
|
528
|
+
const isPanel = (el) => {
|
|
529
|
+
if (transparent(bg(el))) return false;
|
|
530
|
+
if (parseFloat(getComputedStyle(el).borderRadius) <= 0) return false;
|
|
531
|
+
const r = el.getBoundingClientRect();
|
|
532
|
+
return r.width > 160 && r.height > 48;
|
|
533
|
+
};
|
|
534
|
+
const all = Array.from(root.querySelectorAll('*'));
|
|
535
|
+
for (const el of all) {
|
|
536
|
+
if (el.id === 'harness-status' || !visible(el) || isControl(el)) continue;
|
|
537
|
+
if (isBox(el)) {
|
|
538
|
+
let p = el.parentElement; let depth = 0;
|
|
539
|
+
while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
|
|
540
|
+
}
|
|
541
|
+
if (isPanel(el)) {
|
|
542
|
+
let p = el.parentElement; let panels = 0;
|
|
543
|
+
while (p && p !== root) { if (isPanel(p)) panels += 1; p = p.parentElement; }
|
|
544
|
+
if (panels >= 2) push('nested_tinted_panel', el, { depth: panels + 1 });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
548
|
+
let node;
|
|
549
|
+
while ((node = walker.nextNode())) {
|
|
550
|
+
const text = (node.textContent || '').trim();
|
|
551
|
+
if (!text) continue;
|
|
552
|
+
const el = node.parentElement;
|
|
553
|
+
if (!el || el.closest('#harness-status') || !visible(el)) continue;
|
|
554
|
+
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
555
|
+
if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
|
|
556
|
+
if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
|
|
557
|
+
}
|
|
558
|
+
if (root.querySelector('[data-notis-content-skeleton]')) push('loading_placeholder_after_mount', root.querySelector('[data-notis-content-skeleton]'));
|
|
559
|
+
const doc = document.documentElement;
|
|
560
|
+
if (doc.scrollWidth > doc.clientWidth + 1) out.push({ kind: 'horizontal_overflow', snippet: 'document', scroll_width: doc.scrollWidth, client_width: doc.clientWidth });
|
|
561
|
+
return JSON.stringify(out.slice(0, 40));
|
|
562
|
+
})()`;
|
|
563
|
+
|
|
564
|
+
export const DEFAULT_DESIGN_VIEWPORTS = [
|
|
565
|
+
{ name: 'desktop', width: 1280, height: 900 },
|
|
566
|
+
{ name: 'mobile', width: 390, height: 844 },
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
const DESIGN_KIND_MESSAGES = {
|
|
570
|
+
nested_border_box: 'a bordered box sits inside another bordered box',
|
|
571
|
+
nested_tinted_panel: 'tinted panels are nested three deep',
|
|
572
|
+
text_below_12px: 'visible text renders below 12px',
|
|
573
|
+
loading_placeholder_after_mount: 'a loading placeholder is still visible after the route mounted with its data',
|
|
574
|
+
horizontal_overflow: 'the page overflows horizontally',
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
export function describeDesignFinding(finding) {
|
|
578
|
+
const base = DESIGN_KIND_MESSAGES[finding.kind] || finding.kind;
|
|
579
|
+
const where = finding.viewport ? ` at ${finding.viewport}` : '';
|
|
580
|
+
const detail = finding.snippet && finding.snippet !== 'document' ? `: ${finding.snippet}` : '';
|
|
581
|
+
return `${base}${where}${detail}`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Evaluate the design assertions in the current page. Returns an array of
|
|
586
|
+
* findings, or `{ tool_error }` when agent-browser could not run the script.
|
|
587
|
+
*/
|
|
588
|
+
export async function evalDesignAssertions(sessionName, { viewport = null, timeoutMs = 8000 } = {}) {
|
|
589
|
+
const result = await runAgentBrowser(
|
|
590
|
+
['--session', sessionName, '--json', 'eval', DESIGN_ASSERTIONS_SCRIPT],
|
|
591
|
+
{ timeoutMs },
|
|
592
|
+
);
|
|
593
|
+
if (result.exitCode !== 0) {
|
|
594
|
+
return { tool_error: commandError('design_eval', result) };
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const payload = parseAgentBrowserJson(result.stdout);
|
|
598
|
+
const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
|
|
599
|
+
const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
600
|
+
if (!Array.isArray(value)) throw new Error('Design evaluation did not return a findings array.');
|
|
601
|
+
return value.map((finding) => ({ ...finding, viewport: viewport || finding.viewport || null }));
|
|
602
|
+
} catch (error) {
|
|
603
|
+
return { tool_error: { phase: 'design_eval_parse', message: error.message } };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Run every requested viewport. Missing browser checks are incomplete proof,
|
|
609
|
+
* not a passing design result, even when static source lint passed.
|
|
610
|
+
*/
|
|
611
|
+
export async function collectDesignFindings(sessionName, { viewports = DEFAULT_DESIGN_VIEWPORTS, settleMs = 350, skipLoadingCheck = false } = {}) {
|
|
612
|
+
const findings = [];
|
|
613
|
+
let toolError = null;
|
|
614
|
+
for (const viewport of viewports) {
|
|
615
|
+
const applied = await setViewport(sessionName, viewport.width, viewport.height);
|
|
616
|
+
if (!applied) {
|
|
617
|
+
toolError ||= { phase: 'design_viewport', message: `Could not set ${viewport.name} viewport.` };
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
await delay(settleMs);
|
|
621
|
+
const result = await evalDesignAssertions(sessionName, { viewport: viewport.name });
|
|
622
|
+
if (Array.isArray(result)) {
|
|
623
|
+
for (const finding of result) {
|
|
624
|
+
if (skipLoadingCheck && finding.kind === 'loading_placeholder_after_mount') continue;
|
|
625
|
+
findings.push(finding);
|
|
626
|
+
}
|
|
627
|
+
} else if (!toolError) {
|
|
628
|
+
toolError = result.tool_error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { findings, tool_error: toolError };
|
|
632
|
+
}
|
|
@@ -181,3 +181,224 @@ export function validateArtifactBoundary(files) {
|
|
|
181
181
|
);
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Design rules
|
|
187
|
+
//
|
|
188
|
+
// The portal boundary rules above keep an app inside its surface. The design
|
|
189
|
+
// rules below keep it looking like a native, flat Notis page: no bordered
|
|
190
|
+
// boxes around items, no dividers, no palette colors, no eyebrows, no loading
|
|
191
|
+
// text. They are path-scoped, report line numbers, and only ever run on the
|
|
192
|
+
// app's own source files (never on the bundled artifact, where every
|
|
193
|
+
// dependency legitimately contains the word "border").
|
|
194
|
+
//
|
|
195
|
+
// The only override is an inline directive on the same line or the line
|
|
196
|
+
// before the match:
|
|
197
|
+
// // notis-design-allow: <rule-id> <reason of at least N characters>
|
|
198
|
+
// Allowed matches are reported with `allowed: true` so they stay visible.
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
export const DESIGN_RULES_PATH_CANDIDATES = [
|
|
202
|
+
resolve(moduleDir, '../../../../server/config/notis_app_design_rules.json'),
|
|
203
|
+
resolve(moduleDir, '../../config/notis_app_design_rules.json'),
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
export function resolveDesignRulesPath() {
|
|
207
|
+
for (const candidate of DESIGN_RULES_PATH_CANDIDATES) {
|
|
208
|
+
if (existsSync(candidate)) {
|
|
209
|
+
return candidate;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return DESIGN_RULES_PATH_CANDIDATES[DESIGN_RULES_PATH_CANDIDATES.length - 1];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const DEFAULT_DESIGN_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
|
|
216
|
+
|
|
217
|
+
function globToRegExp(glob) {
|
|
218
|
+
let source = '';
|
|
219
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
220
|
+
const char = glob[index];
|
|
221
|
+
if (char === '*') {
|
|
222
|
+
if (glob[index + 1] === '*') {
|
|
223
|
+
index += 1;
|
|
224
|
+
if (glob[index + 1] === '/') {
|
|
225
|
+
index += 1;
|
|
226
|
+
source += '(?:.*/)?';
|
|
227
|
+
} else {
|
|
228
|
+
source += '.*';
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
source += '[^/]*';
|
|
232
|
+
}
|
|
233
|
+
} else if (char === '?') {
|
|
234
|
+
source += '[^/]';
|
|
235
|
+
} else {
|
|
236
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return new RegExp(`^${source}$`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function matchesAny(relPath, globs) {
|
|
243
|
+
return (globs || []).some((glob) => globToRegExp(glob).test(relPath));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let compiledDesignRules = null;
|
|
247
|
+
|
|
248
|
+
export function loadDesignRules({ rulesPath = resolveDesignRulesPath() } = {}) {
|
|
249
|
+
let payload = null;
|
|
250
|
+
try {
|
|
251
|
+
const parsed = JSON.parse(readFileSync(rulesPath, 'utf-8'));
|
|
252
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
253
|
+
payload = parsed;
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
process.stderr.write(
|
|
257
|
+
`Warning: could not load Notis app design rules (${error.message}); skipping design checks.\n`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (!payload) {
|
|
261
|
+
return { include: [], exclude: [], extensions: DEFAULT_DESIGN_EXTENSIONS, rules: [], allowDirective: 'notis-design-allow', allowReasonMinLength: 12 };
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
include: Array.isArray(payload.include) ? payload.include : ['app/**', 'components/**'],
|
|
265
|
+
exclude: Array.isArray(payload.exclude) ? payload.exclude : [],
|
|
266
|
+
extensions: Array.isArray(payload.extensions) ? payload.extensions : DEFAULT_DESIGN_EXTENSIONS,
|
|
267
|
+
allowDirective: typeof payload.allow_directive === 'string' ? payload.allow_directive : 'notis-design-allow',
|
|
268
|
+
allowReasonMinLength: Number.isInteger(payload.allow_reason_min_length) ? payload.allow_reason_min_length : 12,
|
|
269
|
+
rules: (Array.isArray(payload.rules) ? payload.rules : []).map((rule) => ({
|
|
270
|
+
id: rule.id,
|
|
271
|
+
severity: rule.severity === 'warn' ? 'warn' : 'error',
|
|
272
|
+
scope: rule.scope === 'file' ? 'file' : 'line',
|
|
273
|
+
regex: new RegExp(rule.pattern, rule.scope === 'file' ? '' : 'gm'),
|
|
274
|
+
message: rule.message,
|
|
275
|
+
include: Array.isArray(rule.include) ? rule.include : null,
|
|
276
|
+
exclude: Array.isArray(rule.exclude) ? rule.exclude : [],
|
|
277
|
+
})),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getCompiledDesignRules() {
|
|
282
|
+
if (!compiledDesignRules) {
|
|
283
|
+
compiledDesignRules = loadDesignRules();
|
|
284
|
+
}
|
|
285
|
+
return compiledDesignRules;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function lineNumberAt(content, index) {
|
|
289
|
+
let line = 1;
|
|
290
|
+
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
291
|
+
if (content.charCodeAt(cursor) === 10) line += 1;
|
|
292
|
+
}
|
|
293
|
+
return line;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function findAllowDirective(lines, lineNumber, ruleId, directive) {
|
|
297
|
+
const pattern = new RegExp(`${directive}:\\s*${ruleId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*(.*)$`);
|
|
298
|
+
for (const candidate of [lines[lineNumber - 1], lines[lineNumber - 2]]) {
|
|
299
|
+
if (typeof candidate !== 'string') continue;
|
|
300
|
+
const match = candidate.match(pattern);
|
|
301
|
+
if (match) {
|
|
302
|
+
return match[1].replace(/\*\/\s*}?\s*$/, '').replace(/-->\s*$/, '').trim();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function collectDesignViolationsForFile(relPath, content, config = getCompiledDesignRules()) {
|
|
309
|
+
const normalized = relPath.split('\\').join('/');
|
|
310
|
+
if (!config.extensions.includes(extname(normalized))) return [];
|
|
311
|
+
if (!matchesAny(normalized, config.include)) return [];
|
|
312
|
+
if (matchesAny(normalized, config.exclude)) return [];
|
|
313
|
+
|
|
314
|
+
const lines = content.split('\n');
|
|
315
|
+
const violations = [];
|
|
316
|
+
for (const rule of config.rules) {
|
|
317
|
+
if (rule.include && !matchesAny(normalized, rule.include)) continue;
|
|
318
|
+
if (matchesAny(normalized, rule.exclude)) continue;
|
|
319
|
+
|
|
320
|
+
const matches = [];
|
|
321
|
+
if (rule.scope === 'file') {
|
|
322
|
+
const match = rule.regex.exec(content);
|
|
323
|
+
if (match) matches.push(match.index);
|
|
324
|
+
} else {
|
|
325
|
+
rule.regex.lastIndex = 0;
|
|
326
|
+
let match;
|
|
327
|
+
while ((match = rule.regex.exec(content)) !== null) {
|
|
328
|
+
matches.push(match.index);
|
|
329
|
+
if (match[0].length === 0) rule.regex.lastIndex += 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const seenLines = new Set();
|
|
334
|
+
for (const index of matches) {
|
|
335
|
+
const line = lineNumberAt(content, index);
|
|
336
|
+
if (seenLines.has(line)) continue;
|
|
337
|
+
seenLines.add(line);
|
|
338
|
+
const reason = findAllowDirective(lines, line, rule.id, config.allowDirective);
|
|
339
|
+
const allowed = reason !== null && reason.length >= config.allowReasonMinLength;
|
|
340
|
+
violations.push({
|
|
341
|
+
file: normalized,
|
|
342
|
+
line,
|
|
343
|
+
ruleId: rule.id,
|
|
344
|
+
severity: allowed ? 'warn' : rule.severity,
|
|
345
|
+
message: reason !== null && !allowed
|
|
346
|
+
? `${rule.message} (a notis-design-allow directive needs a reason of at least ${config.allowReasonMinLength} characters)`
|
|
347
|
+
: rule.message,
|
|
348
|
+
allowed,
|
|
349
|
+
reason: allowed ? reason : null,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return violations;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function collectProjectDesignViolations(projectDir, config = getCompiledDesignRules()) {
|
|
357
|
+
const files = [];
|
|
358
|
+
collectProjectFiles(projectDir, projectDir, files);
|
|
359
|
+
return files.flatMap((file) => collectDesignViolationsForFile(file.relPath, file.content, config));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function collectSourceDesignViolations(files, config = getCompiledDesignRules()) {
|
|
363
|
+
return Object.entries(files).flatMap(([relPath, rawContent]) => {
|
|
364
|
+
const content = Buffer.isBuffer(rawContent)
|
|
365
|
+
? rawContent.toString('utf-8')
|
|
366
|
+
: typeof rawContent === 'string'
|
|
367
|
+
? rawContent
|
|
368
|
+
: String(rawContent ?? '');
|
|
369
|
+
return collectDesignViolationsForFile(relPath, content, config);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function formatDesignViolation(violation) {
|
|
374
|
+
const prefix = violation.allowed ? 'allowed' : violation.severity;
|
|
375
|
+
const suffix = violation.allowed ? ` (${violation.reason})` : '';
|
|
376
|
+
return `${violation.file}:${violation.line} [${violation.ruleId}] ${prefix}: ${violation.message}${suffix}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Enforce the design rules on an app's source tree.
|
|
381
|
+
*
|
|
382
|
+
* Returns every violation (allowed ones included) so callers can print
|
|
383
|
+
* warnings. When `enforce` is true (build, verify, screenshot, deploy) any
|
|
384
|
+
* unallowed error-severity violation aborts with a usage error that lists the
|
|
385
|
+
* exact file and line to fix. Dev servers pass `enforce: false` so a
|
|
386
|
+
* half-edited page still reloads.
|
|
387
|
+
*/
|
|
388
|
+
export function validateProjectDesign(projectDir, { enforce = true, log = null } = {}) {
|
|
389
|
+
const violations = collectProjectDesignViolations(projectDir);
|
|
390
|
+
const blocking = violations.filter((violation) => !violation.allowed && violation.severity === 'error');
|
|
391
|
+
const nonBlocking = violations.filter((violation) => !blocking.includes(violation));
|
|
392
|
+
if (typeof log === 'function') {
|
|
393
|
+
for (const violation of nonBlocking) log(`[design] ${formatDesignViolation(violation)}`);
|
|
394
|
+
if (!enforce) for (const violation of blocking) log(`[design] ${formatDesignViolation(violation)}`);
|
|
395
|
+
}
|
|
396
|
+
if (enforce && blocking.length > 0) {
|
|
397
|
+
throw usageError(
|
|
398
|
+
`Project violates the Notis app design bar (${blocking.length} issue${blocking.length === 1 ? '' : 's'}). `
|
|
399
|
+
+ 'Fix each line below; the only override is an inline "// notis-design-allow: <rule-id> <reason>" comment on the line before.\n'
|
|
400
|
+
+ blocking.map((violation) => ` - ${formatDesignViolation(violation)}`).join('\n'),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return violations;
|
|
404
|
+
}
|