jig-ui 0.12.0 → 0.14.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/CHANGELOG.md +105 -0
- package/README.md +221 -5
- package/dist/index.js +907 -97
- package/package.json +2 -2
- package/rules/00-anti-patterns.md +115 -0
- package/rules/01-modes.md +1 -1
- package/rules/03-patterns.md +34 -0
- package/rules/05-copy.md +1 -1
- package/rules.index.json +112 -0
- package/templates/COMMAND.md.tmpl +272 -17
- package/templates/command-metadata.json +7 -2
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { readFileSync as
|
|
6
|
-
import { homedir } from "os";
|
|
7
|
-
import { join as
|
|
5
|
+
import { readFileSync as readFileSync30 } from "fs";
|
|
6
|
+
import { homedir as homedir2 } from "os";
|
|
7
|
+
import { join as join37 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -387,8 +387,8 @@ function render(template, vars) {
|
|
|
387
387
|
function escapeTableCell(value) {
|
|
388
388
|
return value.replace(/\|/g, "\\|");
|
|
389
389
|
}
|
|
390
|
-
function renderCommandTable(
|
|
391
|
-
const rows = Object.entries(
|
|
390
|
+
function renderCommandTable(metadata2) {
|
|
391
|
+
const rows = Object.entries(metadata2).map(([name, meta]) => {
|
|
392
392
|
const signature = meta.argumentHint ? `${name} ${meta.argumentHint}` : name;
|
|
393
393
|
const status = meta.status === "planned" ? "planned \u2014 not yet implemented" : meta.status === "agent" ? "agent procedure \u2014 no CLI to run" : "available";
|
|
394
394
|
return `| \`${escapeTableCell(signature)}\` | ${escapeTableCell(meta.description)} | ${status} |`;
|
|
@@ -522,7 +522,7 @@ function bundleFiles(packageRoot2, dir) {
|
|
|
522
522
|
var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
|
|
523
523
|
function buildSkillBody(packageRoot2, rulesPath, version2) {
|
|
524
524
|
const template = readFileSync4(join7(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
|
|
525
|
-
const
|
|
525
|
+
const metadata2 = JSON.parse(
|
|
526
526
|
readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
527
527
|
);
|
|
528
528
|
return render(template, {
|
|
@@ -533,16 +533,16 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
|
|
|
533
533
|
// — so a reader following the skill could never upgrade.
|
|
534
534
|
update_path: "npx jig-ui@latest",
|
|
535
535
|
ask_instruction: ASK_INSTRUCTION,
|
|
536
|
-
available_commands: renderCommandTable(
|
|
536
|
+
available_commands: renderCommandTable(metadata2),
|
|
537
537
|
config_file: "jig.config.json",
|
|
538
538
|
rules_path: rulesPath
|
|
539
539
|
});
|
|
540
540
|
}
|
|
541
541
|
function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
|
|
542
|
-
const
|
|
542
|
+
const metadata2 = JSON.parse(
|
|
543
543
|
readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
544
544
|
);
|
|
545
|
-
const subcommands = Object.entries(
|
|
545
|
+
const subcommands = Object.entries(metadata2).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
|
|
546
546
|
const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
|
|
547
547
|
if (!existsSync5(templatePath)) return { body: "", subcommands };
|
|
548
548
|
const template = readFileSync4(templatePath, "utf8");
|
|
@@ -1472,7 +1472,17 @@ var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
|
1472
1472
|
"out",
|
|
1473
1473
|
"coverage",
|
|
1474
1474
|
".svelte-kit",
|
|
1475
|
-
"vendor"
|
|
1475
|
+
"vendor",
|
|
1476
|
+
// Where the agent harnesses keep their own files, including the copy of
|
|
1477
|
+
// Jig's rules and slash command that `install` vendors. Those are the
|
|
1478
|
+
// system's own text, not the project's interface: scanning them reported
|
|
1479
|
+
// 115 em dashes in Jig's command file and called them the user's copy.
|
|
1480
|
+
".claude",
|
|
1481
|
+
".codex",
|
|
1482
|
+
".cursor",
|
|
1483
|
+
".opencode",
|
|
1484
|
+
".gemini",
|
|
1485
|
+
".github"
|
|
1476
1486
|
]);
|
|
1477
1487
|
function isGitRepo(root) {
|
|
1478
1488
|
try {
|
|
@@ -1796,6 +1806,41 @@ var STYLE_HOST_EXTENSIONS = [
|
|
|
1796
1806
|
function isStyleBearing(file) {
|
|
1797
1807
|
return hasExtension(file, CSS_EXTENSIONS) || hasExtension(file, STYLE_HOST_EXTENSIONS);
|
|
1798
1808
|
}
|
|
1809
|
+
var MARKDOWN_EXTENSIONS = [".md", ".markdown", ".mdx"];
|
|
1810
|
+
var REPO_DOCUMENTS = /* @__PURE__ */ new Set([
|
|
1811
|
+
"readme",
|
|
1812
|
+
"changelog",
|
|
1813
|
+
"contributing",
|
|
1814
|
+
"license",
|
|
1815
|
+
"licence",
|
|
1816
|
+
"notice",
|
|
1817
|
+
"code_of_conduct",
|
|
1818
|
+
"security",
|
|
1819
|
+
"support",
|
|
1820
|
+
"governance",
|
|
1821
|
+
"maintainers",
|
|
1822
|
+
"authors",
|
|
1823
|
+
"agents",
|
|
1824
|
+
"claude",
|
|
1825
|
+
"gemini",
|
|
1826
|
+
"copilot-instructions",
|
|
1827
|
+
// Jig's own: the project's decisions live beside the token layer, in
|
|
1828
|
+
// `jig/DECISIONS.md`, and are written for whoever builds here.
|
|
1829
|
+
"decisions",
|
|
1830
|
+
"reconcile",
|
|
1831
|
+
"coverage",
|
|
1832
|
+
"drift"
|
|
1833
|
+
]);
|
|
1834
|
+
function isRepoDocument(file) {
|
|
1835
|
+
const base = file.split("/").pop() ?? file;
|
|
1836
|
+
const name = base.replace(/\.[^.]+$/, "");
|
|
1837
|
+
if (REPO_DOCUMENTS.has(name.toLowerCase())) return true;
|
|
1838
|
+
return !file.includes("/") && /^[A-Z0-9][A-Z0-9._-]*$/.test(name);
|
|
1839
|
+
}
|
|
1840
|
+
function isReaderText(file) {
|
|
1841
|
+
if (hasExtension(file, MARKDOWN_EXTENSIONS)) return !isRepoDocument(file);
|
|
1842
|
+
return hasExtension(file, STYLE_HOST_EXTENSIONS);
|
|
1843
|
+
}
|
|
1799
1844
|
|
|
1800
1845
|
// src/check/run.ts
|
|
1801
1846
|
import { readFileSync as readFileSync13 } from "fs";
|
|
@@ -3300,58 +3345,283 @@ var undeclaredToken = {
|
|
|
3300
3345
|
};
|
|
3301
3346
|
|
|
3302
3347
|
// src/check/detectors/em-dash.ts
|
|
3303
|
-
var
|
|
3348
|
+
var MARKDOWN = /\.(md|markdown|mdx)$/i;
|
|
3349
|
+
var INDENTED = /\.(pug|jade|haml|slim)$/i;
|
|
3350
|
+
var SCRIPT = /\.(js|mjs|cjs|ts|mts|cts)$/i;
|
|
3351
|
+
var CODEY = /[;{}`]|\$\{|=>/;
|
|
3304
3352
|
var TEXT_ATTRS = /\b(aria-label|aria-description|aria-placeholder|title|placeholder|alt|label|value|content)\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
|
3305
3353
|
var EM_DASH = /—/;
|
|
3354
|
+
var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
|
|
3306
3355
|
function maskNonProse(source) {
|
|
3307
3356
|
const blank3 = (m) => " ".repeat(m.length);
|
|
3308
|
-
return source.replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
3357
|
+
return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
3358
|
+
}
|
|
3359
|
+
function markdownProse(source) {
|
|
3360
|
+
const blank3 = (m) => " ".repeat(m.length);
|
|
3361
|
+
return source.replace(/^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[^\n]*$/gm, blank3).replace(/`[^`\n]*`/g, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\]\([^)\s]*/g, blank3);
|
|
3309
3362
|
}
|
|
3310
|
-
function proseSpans(masked) {
|
|
3363
|
+
function proseSpans(masked, edges) {
|
|
3311
3364
|
const spans = [];
|
|
3365
|
+
const tags = [...masked.matchAll(TAG)];
|
|
3312
3366
|
let last = 0;
|
|
3313
|
-
|
|
3367
|
+
tags.forEach((tag, i) => {
|
|
3314
3368
|
const between = masked.slice(last, tag.index);
|
|
3315
|
-
if (between.trim()) spans.push({ index: last, text: between });
|
|
3369
|
+
if (between.trim() && (edges || i > 0)) spans.push({ index: last, text: between });
|
|
3316
3370
|
for (const attr of tag[0].matchAll(TEXT_ATTRS)) {
|
|
3317
3371
|
const value = attr[3] ?? attr[4] ?? "";
|
|
3318
3372
|
spans.push({ index: tag.index + attr.index + attr[0].indexOf(value), text: value });
|
|
3319
3373
|
}
|
|
3320
3374
|
last = tag.index + tag[0].length;
|
|
3321
|
-
}
|
|
3375
|
+
});
|
|
3322
3376
|
const tail = masked.slice(last);
|
|
3323
|
-
if (tail.trim()) spans.push({ index: last, text: tail });
|
|
3377
|
+
if (tail.trim() && edges && tags.length > 0) spans.push({ index: last, text: tail });
|
|
3324
3378
|
return spans;
|
|
3325
3379
|
}
|
|
3380
|
+
function indentedProse(source) {
|
|
3381
|
+
const blank3 = (m) => " ".repeat(m.length);
|
|
3382
|
+
return source.split("\n").map((line) => /^\s*(-|\/\/|#(?!\w)|=)/.test(line) ? blank3(line) : line).join("\n");
|
|
3383
|
+
}
|
|
3326
3384
|
var emDash = {
|
|
3327
3385
|
name: "em-dash",
|
|
3328
|
-
appliesTo: (file) =>
|
|
3386
|
+
appliesTo: (file) => isReaderText(file),
|
|
3329
3387
|
run(_source, file, ctx) {
|
|
3330
|
-
const
|
|
3388
|
+
const markdown = MARKDOWN.test(file);
|
|
3389
|
+
const indented = INDENTED.test(file);
|
|
3390
|
+
const masked = markdown ? markdownProse(ctx.raw) : indented ? indentedProse(maskNonProse(ctx.raw)) : maskNonProse(ctx.raw);
|
|
3331
3391
|
const starts = buildLineIndex(ctx.raw);
|
|
3332
3392
|
const findings = [];
|
|
3333
3393
|
const seen = /* @__PURE__ */ new Set();
|
|
3334
|
-
|
|
3394
|
+
const script = SCRIPT.test(file);
|
|
3395
|
+
const spans = markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
|
|
3396
|
+
for (const span of spans) {
|
|
3335
3397
|
if (!EM_DASH.test(span.text)) continue;
|
|
3336
|
-
const
|
|
3398
|
+
for (const hit of span.text.matchAll(/\u2014/g)) {
|
|
3399
|
+
const line = lineForOffset(starts, span.index + hit.index);
|
|
3400
|
+
if (seen.has(line)) continue;
|
|
3401
|
+
seen.add(line);
|
|
3402
|
+
findings.push(
|
|
3403
|
+
mkFinding(
|
|
3404
|
+
ctx,
|
|
3405
|
+
"em-dash",
|
|
3406
|
+
file,
|
|
3407
|
+
line,
|
|
3408
|
+
"an em dash in interface text: use a full stop, a comma, a colon, or a second element",
|
|
3409
|
+
sourceLine(ctx.raw, line)
|
|
3410
|
+
)
|
|
3411
|
+
);
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
3415
|
+
}
|
|
3416
|
+
};
|
|
3417
|
+
|
|
3418
|
+
// src/check/detectors/semantic-element.ts
|
|
3419
|
+
var BODY = /<body\b/i;
|
|
3420
|
+
var MAIN = /<main\b|role\s*=\s*["']main["']/i;
|
|
3421
|
+
var NAV_ELEMENT = /<nav\b|role\s*=\s*["']navigation["']/i;
|
|
3422
|
+
var CHROME = /<(header|footer)\b[\s\S]*?<\/\1\s*>/gi;
|
|
3423
|
+
var NAV_CONTAINER = /<(div|section|ul|span)\b[^>]*\b(?:class|className|id)\s*=\s*["'][^"']*\b(?:nav|navigation|navbar|menu)\b[^"']*["'][^>]*>/i;
|
|
3424
|
+
var LINK = /<a\b[^>]*\bhref\b/gi;
|
|
3425
|
+
var HEADING_SHAPED = /<(div|span|p)\b[^>]*\b(?:class|className)\s*=\s*["']([^"']*\b(?:title|heading|headline|text-(?:2xl|3xl|4xl|5xl))\b[^"']*)["'][^>]*>([\s\S]{0,120}?)<\/\1\s*>/gi;
|
|
3426
|
+
var HEADING_INSIDE = /<h[1-6]\b/i;
|
|
3427
|
+
var REPEATED = /<(div|article|section|a)\b[^>]*\b(?:class|className)\s*=\s*["']([^"']+)["']/gi;
|
|
3428
|
+
var LIST_OR_TABLE = /<(ul|ol|dl|table|tbody|menu)\b/i;
|
|
3429
|
+
var semanticElement = {
|
|
3430
|
+
name: "semantic-element",
|
|
3431
|
+
appliesTo: (file) => isReaderText(file),
|
|
3432
|
+
run(_source, file, ctx) {
|
|
3433
|
+
const raw = ctx.raw;
|
|
3434
|
+
if (!BODY.test(raw)) return [];
|
|
3435
|
+
const starts = buildLineIndex(raw);
|
|
3436
|
+
const findings = [];
|
|
3437
|
+
const at = (offset, message) => {
|
|
3337
3438
|
const line = lineForOffset(starts, offset);
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
file,
|
|
3345
|
-
line,
|
|
3346
|
-
"an em dash in interface text: use a full stop, a comma, a colon, or a second element",
|
|
3347
|
-
sourceLine(ctx.raw, line)
|
|
3348
|
-
)
|
|
3439
|
+
findings.push(mkFinding(ctx, "semantic-element", file, line, message, sourceLine(raw, line)));
|
|
3440
|
+
};
|
|
3441
|
+
if (!MAIN.test(raw)) {
|
|
3442
|
+
at(
|
|
3443
|
+
BODY.exec(raw).index,
|
|
3444
|
+
"this page has no <main> \u2014 nothing tells a reader skipping the chrome where the page itself starts"
|
|
3349
3445
|
);
|
|
3350
3446
|
}
|
|
3447
|
+
if (!NAV_ELEMENT.test(raw)) {
|
|
3448
|
+
const container = NAV_CONTAINER.exec(raw);
|
|
3449
|
+
const links = (raw.match(LINK) ?? []).length;
|
|
3450
|
+
if (container && links >= 3) {
|
|
3451
|
+
at(container.index, "navigation links in a generic container, with no <nav> on the page \u2014 the element is what makes it a landmark, not the class name");
|
|
3452
|
+
} else {
|
|
3453
|
+
for (const region of raw.matchAll(CHROME)) {
|
|
3454
|
+
if ((region[0].match(LINK) ?? []).length < 3) continue;
|
|
3455
|
+
at(region.index, `the ${region[1].toLowerCase()} holds a row of links and the page has no <nav> \u2014 a set of destinations is a navigation landmark, and the element is what makes it one`);
|
|
3456
|
+
break;
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
for (const m of raw.matchAll(HEADING_SHAPED)) {
|
|
3461
|
+
if (HEADING_INSIDE.test(m[3])) continue;
|
|
3462
|
+
const text = m[3].replace(/<[^>]*>/g, "").trim();
|
|
3463
|
+
if (!text || text.length > 80) continue;
|
|
3464
|
+
at(m.index, `"${text.slice(0, 40)}" is a ${m[1].toLowerCase()} named and styled as a heading \u2014 if it is the heading, it is an h1-h6, and CSS gives it the size`);
|
|
3465
|
+
}
|
|
3466
|
+
if (!LIST_OR_TABLE.test(raw)) {
|
|
3467
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3468
|
+
for (const m of raw.matchAll(REPEATED)) {
|
|
3469
|
+
const key = `${m[1].toLowerCase()}.${m[2].trim()}`;
|
|
3470
|
+
const seen = counts.get(key) ?? { n: 0, index: m.index };
|
|
3471
|
+
counts.set(key, { n: seen.n + 1, index: seen.index });
|
|
3472
|
+
}
|
|
3473
|
+
const repeated = [...counts.entries()].filter(([, v]) => v.n >= 3).sort((a, b) => a[1].index - b[1].index)[0];
|
|
3474
|
+
if (repeated) {
|
|
3475
|
+
at(repeated[1].index, `${repeated[1].n} sibling ${repeated[0]} elements are a repeated set, and nothing on this page is a list or a table \u2014 decide which collection this is (ul, ol, dl, table) before using a generic container`);
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3351
3478
|
return findings;
|
|
3352
3479
|
}
|
|
3353
3480
|
};
|
|
3354
3481
|
|
|
3482
|
+
// src/check/metadata.ts
|
|
3483
|
+
var QUOTED = `(?:"([^"]*)"|'([^']*)'|\`([^\`]*)\`)`;
|
|
3484
|
+
var HTML_TITLE = /<title[^>]*>([\s\S]*?)<\/title\s*>/i;
|
|
3485
|
+
var HTML_DESCRIPTION = /<meta[^>]+name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']*)["']/i;
|
|
3486
|
+
var HTML_DESCRIPTION_REVERSED = /<meta[^>]+content\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']description["']/i;
|
|
3487
|
+
var KEYED_TITLE = new RegExp(`(?:^|[\\s{,])(?:title|ogTitle|og:title)\\s*:\\s*${QUOTED}`, "m");
|
|
3488
|
+
var KEYED_TITLE_ALL = new RegExp(`(?:^|[\\s{,])(?:title|ogTitle|og:title)\\s*:\\s*${QUOTED}`, "gm");
|
|
3489
|
+
var HTML_TITLE_ALL = /<title[^>]*>([\s\S]*?)<\/title\s*>/gi;
|
|
3490
|
+
var KEYED_DESCRIPTION = new RegExp(`(?:^|[\\s{,])(?:description|ogDescription|og:description)\\s*:\\s*${QUOTED}`, "m");
|
|
3491
|
+
var COMPUTED_TITLE = /(?:^|[\s{,])(?:title|ogTitle)\s*:\s*(?!["'`])[A-Za-z_$][\w$.?[\]()]*|(?:^|[\s{,])(?:title|ogTitle)\s*:\s*`[^`]*\$\{/m;
|
|
3492
|
+
var ANY_TITLE = /<title[\s>]|(?:^|[\s{,])(?:title|ogTitle)\s*:|['"]title['"]\s*[,:]|titleTemplate/im;
|
|
3493
|
+
var ANY_DESCRIPTION = /name\s*=\s*["']description["']|(?:^|[\s{,])(?:description|ogDescription)\s*:|['"]description['"]\s*[,:]/im;
|
|
3494
|
+
var DECLARES = /export\s+(?:const|async\s+function|function)\s+(?:metadata|generateMetadata|meta)\b|useSeoMeta\s*\(|useHead\s*\(|defineRouteMeta\s*\(|<svelte:head|<Head\b|next\/head|react-helmet|<title[^>]*>|<meta\b/i;
|
|
3495
|
+
var NOINDEX = [
|
|
3496
|
+
/<meta[^>]+name\s*=\s*["']robots["'][^>]*content\s*=\s*["'][^"']*noindex/i,
|
|
3497
|
+
/robots\s*:\s*{[^}]*index\s*:\s*false/i,
|
|
3498
|
+
/robots\s*:\s*["'][^"']*noindex/i,
|
|
3499
|
+
/["']noindex["']/i,
|
|
3500
|
+
/X-Robots-Tag[^\n]*noindex/i,
|
|
3501
|
+
/noindex\s*:\s*true/i
|
|
3502
|
+
];
|
|
3503
|
+
function firstGroup(match) {
|
|
3504
|
+
if (!match) return void 0;
|
|
3505
|
+
const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
3506
|
+
if (!value) return void 0;
|
|
3507
|
+
return { value, index: match.index ?? 0 };
|
|
3508
|
+
}
|
|
3509
|
+
function readMetadata(source) {
|
|
3510
|
+
const htmlTitle = firstGroup(HTML_TITLE.exec(source));
|
|
3511
|
+
const title = htmlTitle ?? firstGroup(KEYED_TITLE.exec(source));
|
|
3512
|
+
const description = firstGroup(HTML_DESCRIPTION.exec(source)) ?? firstGroup(HTML_DESCRIPTION_REVERSED.exec(source)) ?? firstGroup(KEYED_DESCRIPTION.exec(source));
|
|
3513
|
+
const titles = [];
|
|
3514
|
+
for (const re of [HTML_TITLE_ALL, KEYED_TITLE_ALL]) {
|
|
3515
|
+
for (const match of source.matchAll(re)) {
|
|
3516
|
+
const found = firstGroup(match);
|
|
3517
|
+
if (found && !/^\{|\$\{/.test(found.value)) titles.push(found);
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
return {
|
|
3521
|
+
titles,
|
|
3522
|
+
title: title && !/^\{|\$\{/.test(title.value) ? title : void 0,
|
|
3523
|
+
description: description && !/^\{|\$\{/.test(description.value) ? description : void 0,
|
|
3524
|
+
hasTitle: ANY_TITLE.test(source),
|
|
3525
|
+
hasDescription: ANY_DESCRIPTION.test(source),
|
|
3526
|
+
declares: DECLARES.test(source),
|
|
3527
|
+
computedTitle: COMPUTED_TITLE.test(source),
|
|
3528
|
+
noindex: NOINDEX.some((re) => re.test(source))
|
|
3529
|
+
};
|
|
3530
|
+
}
|
|
3531
|
+
function isWholeDocument(source) {
|
|
3532
|
+
return /<html[\s>]/i.test(source) && /<head[\s>]/i.test(source);
|
|
3533
|
+
}
|
|
3534
|
+
var TITLE_BUDGET = 60;
|
|
3535
|
+
var DESCRIPTION_BUDGET = 155;
|
|
3536
|
+
|
|
3537
|
+
// src/check/detectors/metadata.ts
|
|
3538
|
+
var INVENTED_DATE = /(?:lastModified|datePublished|dateModified|publishedTime|modifiedTime)\s*:\s*(?:new Date\(\s*\)|Date\.now\(\s*\))/;
|
|
3539
|
+
var metadata = {
|
|
3540
|
+
name: "metadata",
|
|
3541
|
+
appliesTo: (file) => isReaderText(file),
|
|
3542
|
+
run(_source, file, ctx) {
|
|
3543
|
+
const raw = ctx.raw;
|
|
3544
|
+
const found = readMetadata(raw);
|
|
3545
|
+
const starts = buildLineIndex(raw);
|
|
3546
|
+
const findings = [];
|
|
3547
|
+
const at = (rule, offset, message) => {
|
|
3548
|
+
const line = lineForOffset(starts, offset);
|
|
3549
|
+
findings.push({ ...mkFinding(ctx, "metadata", file, line, message, sourceLine(raw, line)), ruleId: rule });
|
|
3550
|
+
};
|
|
3551
|
+
for (const title of found.titles) {
|
|
3552
|
+
if (title.value.length > TITLE_BUDGET) {
|
|
3553
|
+
at("J-122", title.index, `the title is ${title.value.length} characters, past the ${TITLE_BUDGET} a search result shows \u2014 the end is cut, mid-sentence`);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
if (found.description && found.description.value.length > DESCRIPTION_BUDGET) {
|
|
3557
|
+
at("J-122", found.description.index, `the description is ${found.description.value.length} characters, past the ${DESCRIPTION_BUDGET} a search result shows`);
|
|
3558
|
+
}
|
|
3559
|
+
const invented = INVENTED_DATE.exec(raw);
|
|
3560
|
+
if (invented) {
|
|
3561
|
+
at("J-125", invented.index, "a date generated at request time is false on every request, and repeated daily it teaches the crawler to disbelieve the field");
|
|
3562
|
+
}
|
|
3563
|
+
if (isWholeDocument(raw)) {
|
|
3564
|
+
const indexable = ctx.mode !== "product" && ctx.mode !== "operator";
|
|
3565
|
+
if (indexable && !found.hasTitle) {
|
|
3566
|
+
at("J-121", 0, "this page has no <title> \u2014 a search result then shows a truncated URL");
|
|
3567
|
+
}
|
|
3568
|
+
if (indexable && !found.hasDescription) {
|
|
3569
|
+
at("J-121", 0, "this page has no meta description \u2014 the search engine writes one from whatever text it finds first, usually the navigation");
|
|
3570
|
+
}
|
|
3571
|
+
if (!indexable && !found.noindex) {
|
|
3572
|
+
at("J-123", 0, `this surface is ${ctx.mode}, which is what somebody reaches after signing in, and the page carries no noindex \u2014 robots.txt is public and advisory, and is not this`);
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
return findings.filter((f) => f.ruleId === ctx.ruleId);
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
|
|
3579
|
+
// src/check/detectors/interface-safety.ts
|
|
3580
|
+
var BLANK_LINK = /<a\b[^>]*\btarget\s*=\s*["']_blank["'][^>]*>/gi;
|
|
3581
|
+
var HAS_NOOPENER = /\brel\s*=\s*["'][^"']*\bno(?:opener|referrer)\b/i;
|
|
3582
|
+
var HTML_SINK = /\bdangerouslySetInnerHTML|\bv-html|\binnerHTML\s*=|\{@html\b/g;
|
|
3583
|
+
var IN_SCRIPT = /<script\b[^>]*$/i;
|
|
3584
|
+
var SERIALISED = /JSON\.stringify\s*\(/;
|
|
3585
|
+
var LITERAL_SINK = /dangerouslySetInnerHTML\s*=\s*\{\{\s*__html:\s*["'`]|innerHTML\s*=\s*["'`]/;
|
|
3586
|
+
var CREDENTIAL_FIELD = /<input\b[^>]*\btype\s*=\s*["'](?:password)["'][^>]*>/gi;
|
|
3587
|
+
var AUTOCOMPLETE_OFF = /\bautocomplete\s*=\s*["'](?:off|false)["']/i;
|
|
3588
|
+
var IFRAME = /<iframe\b[^>]*>/gi;
|
|
3589
|
+
var HAS_SANDBOX = /\bsandbox\s*=/i;
|
|
3590
|
+
var EXTERNAL_SRC = /\bsrc\s*=\s*["'](?:https?:)?\/\//i;
|
|
3591
|
+
var interfaceSafety = {
|
|
3592
|
+
name: "interface-safety",
|
|
3593
|
+
appliesTo: (file) => isReaderText(file),
|
|
3594
|
+
run(_source, file, ctx) {
|
|
3595
|
+
const raw = ctx.raw;
|
|
3596
|
+
const starts = buildLineIndex(raw);
|
|
3597
|
+
const findings = [];
|
|
3598
|
+
const at = (rule, offset, message) => {
|
|
3599
|
+
const line = lineForOffset(starts, offset);
|
|
3600
|
+
findings.push({ ...mkFinding(ctx, "interface-safety", file, line, message, sourceLine(raw, line)), ruleId: rule });
|
|
3601
|
+
};
|
|
3602
|
+
for (const link of raw.matchAll(BLANK_LINK)) {
|
|
3603
|
+
if (HAS_NOOPENER.test(link[0])) continue;
|
|
3604
|
+
at("K-128", link.index, 'a link opening a new tab with no rel="noopener" \u2014 the page it opens gets a handle on this window and can navigate it elsewhere');
|
|
3605
|
+
}
|
|
3606
|
+
for (const sink of raw.matchAll(HTML_SINK)) {
|
|
3607
|
+
const context = raw.slice(sink.index, sink.index + 120);
|
|
3608
|
+
if (LITERAL_SINK.test(context) || SERIALISED.test(context)) continue;
|
|
3609
|
+
const before = raw.slice(Math.max(0, sink.index - 400), sink.index);
|
|
3610
|
+
if (IN_SCRIPT.test(before.slice(before.lastIndexOf("<")))) continue;
|
|
3611
|
+
at("K-129", sink.index, `${sink[0].replace(/\s*=$/, "")} writes a value into the page as markup \u2014 render it as text, or sanitise on the way in`);
|
|
3612
|
+
}
|
|
3613
|
+
for (const field of raw.matchAll(CREDENTIAL_FIELD)) {
|
|
3614
|
+
if (!AUTOCOMPLETE_OFF.test(field[0])) continue;
|
|
3615
|
+
at("K-130", field.index, "a password field with autocomplete off \u2014 this stops the password manager, not an attacker, and the reader falls back to one they can remember");
|
|
3616
|
+
}
|
|
3617
|
+
for (const frame of raw.matchAll(IFRAME)) {
|
|
3618
|
+
if (HAS_SANDBOX.test(frame[0]) || !EXTERNAL_SRC.test(frame[0])) continue;
|
|
3619
|
+
at("K-131", frame.index, "a frame of somebody else's page with no sandbox \u2014 give it only the capabilities the embed needs");
|
|
3620
|
+
}
|
|
3621
|
+
return findings.filter((f) => f.ruleId === ctx.ruleId);
|
|
3622
|
+
}
|
|
3623
|
+
};
|
|
3624
|
+
|
|
3355
3625
|
// src/check/registry.ts
|
|
3356
3626
|
var DETECTORS = [
|
|
3357
3627
|
gradientText,
|
|
@@ -3370,7 +3640,10 @@ var DETECTORS = [
|
|
|
3370
3640
|
safeArea,
|
|
3371
3641
|
menuState,
|
|
3372
3642
|
undeclaredToken,
|
|
3373
|
-
emDash
|
|
3643
|
+
emDash,
|
|
3644
|
+
semanticElement,
|
|
3645
|
+
metadata,
|
|
3646
|
+
interfaceSafety
|
|
3374
3647
|
];
|
|
3375
3648
|
function getDetector(name) {
|
|
3376
3649
|
return DETECTORS.find((d) => d.name === name);
|
|
@@ -4934,8 +5207,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4934
5207
|
}
|
|
4935
5208
|
|
|
4936
5209
|
// src/commands/verdicts.ts
|
|
4937
|
-
import { existsSync as
|
|
4938
|
-
import { join as
|
|
5210
|
+
import { existsSync as existsSync20, readFileSync as readFileSync26 } from "fs";
|
|
5211
|
+
import { join as join33, resolve as resolve4 } from "path";
|
|
4939
5212
|
|
|
4940
5213
|
// src/rules/citations.ts
|
|
4941
5214
|
import { join as join28 } from "path";
|
|
@@ -4949,16 +5222,16 @@ function citableIds(packageRoot2) {
|
|
|
4949
5222
|
}
|
|
4950
5223
|
|
|
4951
5224
|
// src/probe/check.ts
|
|
4952
|
-
import { existsSync as
|
|
4953
|
-
import { join as
|
|
5225
|
+
import { existsSync as existsSync18, readdirSync as readdirSync13, readFileSync as readFileSync24 } from "fs";
|
|
5226
|
+
import { join as join31 } from "path";
|
|
4954
5227
|
|
|
4955
5228
|
// src/probe/save.ts
|
|
4956
|
-
import { existsSync as
|
|
5229
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
|
|
4957
5230
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4958
|
-
import { join as
|
|
5231
|
+
import { join as join30, relative as relative3, resolve as resolve3 } from "path";
|
|
4959
5232
|
|
|
4960
5233
|
// src/probe/script.ts
|
|
4961
|
-
var PROBE_VERSION =
|
|
5234
|
+
var PROBE_VERSION = 5;
|
|
4962
5235
|
var PROBE_SCRIPT = `(async () => {
|
|
4963
5236
|
const doc = document.documentElement;
|
|
4964
5237
|
const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
|
|
@@ -4978,6 +5251,62 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
4978
5251
|
}
|
|
4979
5252
|
}
|
|
4980
5253
|
}
|
|
5254
|
+
// Markup order against reading order (H-119). Only a vertical inversion
|
|
5255
|
+
// counts: an element that sits entirely ABOVE another on screen while coming
|
|
5256
|
+
// after it in the markup. Two columns side by side are not an inversion \u2014
|
|
5257
|
+
// putting the sidebar after the main content and moving it left with CSS is
|
|
5258
|
+
// the correct pattern, and flagging it would punish the right answer.
|
|
5259
|
+
// What to call a region in a report: its own label, else its heading, else
|
|
5260
|
+
// nothing. Its whole text content reads as gibberish inside a finding.
|
|
5261
|
+
const label = (el) => {
|
|
5262
|
+
const aria = (el.getAttribute('aria-label') || '').trim();
|
|
5263
|
+
if (aria) return aria;
|
|
5264
|
+
const id = el.getAttribute('aria-labelledby');
|
|
5265
|
+
const target = id ? document.getElementById(id) : null;
|
|
5266
|
+
const heading = target || (/^h[1-6]$/i.test(el.tagName) ? el : el.querySelector('h1, h2, h3, h4, h5, h6'));
|
|
5267
|
+
const t = (heading ? heading.textContent : '').replace(/\\s+/g, ' ').trim();
|
|
5268
|
+
return t.length > 42 ? t.slice(0, 40).trimEnd() + '\u2026' : t;
|
|
5269
|
+
};
|
|
5270
|
+
const all = [...document.querySelectorAll('main, nav, aside, header, footer, section, article, h1, h2, h3')].filter(vis);
|
|
5271
|
+
// Only the outermost regions. A nested heading inverts with its own parent
|
|
5272
|
+
// and with every sibling of it, so one swapped column reported four times.
|
|
5273
|
+
const landmarks = all
|
|
5274
|
+
.filter((el) => !all.some((other) => other !== el && other.contains(el)))
|
|
5275
|
+
.map((el, order) => {
|
|
5276
|
+
const r = el.getBoundingClientRect();
|
|
5277
|
+
return {
|
|
5278
|
+
order,
|
|
5279
|
+
tag: el.tagName.toLowerCase(),
|
|
5280
|
+
name: label(el),
|
|
5281
|
+
top: Math.round(r.top + scrollY),
|
|
5282
|
+
bottom: Math.round(r.bottom + scrollY),
|
|
5283
|
+
};
|
|
5284
|
+
});
|
|
5285
|
+
const describe = (l) => '<' + l.tag + '>' + (l.name ? ' \u201C' + l.name + '\u201D' : '');
|
|
5286
|
+
const inversions = [];
|
|
5287
|
+
for (let i = 0; i < landmarks.length; i++) {
|
|
5288
|
+
for (let j = i + 1; j < landmarks.length; j++) {
|
|
5289
|
+
const earlier = landmarks[i], later = landmarks[j];
|
|
5290
|
+
// The later element sits wholly above the earlier one, past a rounding wobble.
|
|
5291
|
+
if (later.bottom - earlier.top <= 8 && later.top < earlier.top) {
|
|
5292
|
+
inversions.push({ markupFirst: describe(earlier), seenFirst: describe(later) });
|
|
5293
|
+
}
|
|
5294
|
+
}
|
|
5295
|
+
}
|
|
5296
|
+
// What a wide screen exposes: a layout with no upper bound, and the lines it
|
|
5297
|
+
// stretches. Measured rather than guessed \u2014 a paragraph's own width and font
|
|
5298
|
+
// size give its length in characters, which is what B-11 is written about.
|
|
5299
|
+
const region = document.querySelector('main') || document.body;
|
|
5300
|
+
const prose = [...document.querySelectorAll('p, li, dd, blockquote')].filter(vis)
|
|
5301
|
+
.map((el) => {
|
|
5302
|
+
const r = el.getBoundingClientRect();
|
|
5303
|
+
const size = parseFloat(getComputedStyle(el).fontSize) || 16;
|
|
5304
|
+
const text = (el.textContent || '').trim();
|
|
5305
|
+
// 0.5em per character is the usual approximation for a text face.
|
|
5306
|
+
return { width: Math.round(r.width), chars: Math.round(r.width / (size * 0.5)), text: text.slice(0, 40), long: text.length > 80 };
|
|
5307
|
+
})
|
|
5308
|
+
.filter((p) => p.long)
|
|
5309
|
+
.sort((a, b) => b.chars - a.chars)[0] || null;
|
|
4981
5310
|
const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
|
|
4982
5311
|
// Measured before anything is clicked: what a reader sees on arrival.
|
|
4983
5312
|
const navAtRest = navLinks();
|
|
@@ -5022,12 +5351,194 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5022
5351
|
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
5023
5352
|
unresolvedTokens: [...unresolved],
|
|
5024
5353
|
junkText: junk,
|
|
5354
|
+
emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
|
|
5025
5355
|
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
|
|
5026
5356
|
navLinksVisible: navAtRest,
|
|
5357
|
+
head: {
|
|
5358
|
+
title: (document.title || '').trim(),
|
|
5359
|
+
description: (document.querySelector('meta[name=description]') || {}).content || '',
|
|
5360
|
+
canonical: (document.querySelector('link[rel=canonical]') || {}).href || '',
|
|
5361
|
+
robots: (document.querySelector('meta[name=robots]') || {}).content || '',
|
|
5362
|
+
ogTitle: (document.querySelector('meta[property="og:title"]') || {}).content || '',
|
|
5363
|
+
ogImage: (document.querySelector('meta[property="og:image"]') || {}).content || '',
|
|
5364
|
+
},
|
|
5365
|
+
contentWidth: Math.round(region.getBoundingClientRect().width),
|
|
5366
|
+
contentMaxWidth: getComputedStyle(region).maxWidth,
|
|
5367
|
+
longestLine: prose,
|
|
5368
|
+
landmarks: landmarks.map(describe),
|
|
5369
|
+
orderInversions: inversions.slice(0, 5),
|
|
5027
5370
|
menu,
|
|
5028
5371
|
});
|
|
5029
5372
|
})()`;
|
|
5030
5373
|
|
|
5374
|
+
// src/probe/browser.ts
|
|
5375
|
+
import { spawn } from "child_process";
|
|
5376
|
+
import { existsSync as existsSync16, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
|
|
5377
|
+
import { homedir, tmpdir } from "os";
|
|
5378
|
+
import { join as join29 } from "path";
|
|
5379
|
+
var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
|
|
5380
|
+
var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
|
|
5381
|
+
var CHROME_APPS = [
|
|
5382
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
5383
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
5384
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
5385
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
5386
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
5387
|
+
];
|
|
5388
|
+
function cachedBrowsers() {
|
|
5389
|
+
const roots = [
|
|
5390
|
+
join29(homedir(), ".cache", "ms-playwright"),
|
|
5391
|
+
join29(homedir(), "Library", "Caches", "ms-playwright"),
|
|
5392
|
+
join29(homedir(), "AppData", "Local", "ms-playwright"),
|
|
5393
|
+
join29(homedir(), ".cache", "puppeteer", "chrome")
|
|
5394
|
+
];
|
|
5395
|
+
const found = [];
|
|
5396
|
+
for (const root of roots) {
|
|
5397
|
+
let entries;
|
|
5398
|
+
try {
|
|
5399
|
+
entries = readdirSync11(root);
|
|
5400
|
+
} catch {
|
|
5401
|
+
continue;
|
|
5402
|
+
}
|
|
5403
|
+
for (const entry of entries.sort().reverse()) {
|
|
5404
|
+
for (const rel of [
|
|
5405
|
+
["chrome-linux", "chrome"],
|
|
5406
|
+
["chrome-linux64", "chrome"],
|
|
5407
|
+
["chrome-headless-shell-linux64", "chrome-headless-shell"],
|
|
5408
|
+
["chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"],
|
|
5409
|
+
["chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"],
|
|
5410
|
+
["chrome-win", "chrome.exe"],
|
|
5411
|
+
["chrome-win64", "chrome.exe"]
|
|
5412
|
+
]) {
|
|
5413
|
+
const candidate = join29(root, entry, ...rel);
|
|
5414
|
+
if (existsSync16(candidate)) found.push(candidate);
|
|
5415
|
+
}
|
|
5416
|
+
}
|
|
5417
|
+
}
|
|
5418
|
+
return found;
|
|
5419
|
+
}
|
|
5420
|
+
function findChrome() {
|
|
5421
|
+
for (const key of CHROME_ENV) {
|
|
5422
|
+
const value = process.env[key];
|
|
5423
|
+
if (value && existsSync16(value)) return value;
|
|
5424
|
+
}
|
|
5425
|
+
for (const command of CHROME_COMMANDS) {
|
|
5426
|
+
const resolved = which(command);
|
|
5427
|
+
if (resolved) return resolved;
|
|
5428
|
+
}
|
|
5429
|
+
for (const app of CHROME_APPS) if (existsSync16(app)) return app;
|
|
5430
|
+
return cachedBrowsers()[0];
|
|
5431
|
+
}
|
|
5432
|
+
function which(command) {
|
|
5433
|
+
const dirs = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
|
|
5434
|
+
for (const dir of dirs) {
|
|
5435
|
+
if (!dir) continue;
|
|
5436
|
+
const candidate = join29(dir, command);
|
|
5437
|
+
if (existsSync16(candidate)) return candidate;
|
|
5438
|
+
}
|
|
5439
|
+
return void 0;
|
|
5440
|
+
}
|
|
5441
|
+
async function runProbe(opts) {
|
|
5442
|
+
const chrome = opts.chrome ?? findChrome();
|
|
5443
|
+
if (!chrome) {
|
|
5444
|
+
throw new Error(
|
|
5445
|
+
"No browser found to run the probe. Install one, or set JIG_CHROME to a Chrome, Chromium or Edge binary. A project with Playwright or Puppeteer already has one, and Jig will find it."
|
|
5446
|
+
);
|
|
5447
|
+
}
|
|
5448
|
+
const profile = mkdtempSync(join29(tmpdir(), "jig-probe-"));
|
|
5449
|
+
const child = spawn(chrome, [
|
|
5450
|
+
"--headless=new",
|
|
5451
|
+
"--remote-debugging-port=0",
|
|
5452
|
+
`--user-data-dir=${profile}`,
|
|
5453
|
+
"--no-first-run",
|
|
5454
|
+
"--no-default-browser-check",
|
|
5455
|
+
"--disable-gpu",
|
|
5456
|
+
"--hide-scrollbars",
|
|
5457
|
+
"--disable-extensions",
|
|
5458
|
+
"--disable-background-networking",
|
|
5459
|
+
"--no-sandbox",
|
|
5460
|
+
"about:blank"
|
|
5461
|
+
], { stdio: ["ignore", "ignore", "pipe"] });
|
|
5462
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
5463
|
+
try {
|
|
5464
|
+
const endpoint = await new Promise((done, fail) => {
|
|
5465
|
+
const timer = setTimeout(() => fail(new Error(`${chrome} did not start within ${timeoutMs / 1e3}s.`)), timeoutMs);
|
|
5466
|
+
let buffered = "";
|
|
5467
|
+
child.stderr.on("data", (chunk) => {
|
|
5468
|
+
buffered += chunk.toString();
|
|
5469
|
+
const match = /ws:\/\/[^\s]+/.exec(buffered);
|
|
5470
|
+
if (match) {
|
|
5471
|
+
clearTimeout(timer);
|
|
5472
|
+
done(match[0]);
|
|
5473
|
+
}
|
|
5474
|
+
});
|
|
5475
|
+
child.on("exit", (code) => {
|
|
5476
|
+
clearTimeout(timer);
|
|
5477
|
+
fail(new Error(`${chrome} exited with code ${code} before it was ready.`));
|
|
5478
|
+
});
|
|
5479
|
+
});
|
|
5480
|
+
return await evaluate(endpoint, opts.url, opts.width, opts.height ?? 900, timeoutMs);
|
|
5481
|
+
} finally {
|
|
5482
|
+
child.kill();
|
|
5483
|
+
rmSync3(profile, { recursive: true, force: true });
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
async function evaluate(endpoint, url, width, height, timeoutMs) {
|
|
5487
|
+
const socket = new WebSocket(endpoint);
|
|
5488
|
+
let nextId = 1;
|
|
5489
|
+
const pending = /* @__PURE__ */ new Map();
|
|
5490
|
+
const loaded = { fired: false, waiters: [] };
|
|
5491
|
+
await new Promise((done, fail) => {
|
|
5492
|
+
socket.addEventListener("open", () => done(), { once: true });
|
|
5493
|
+
socket.addEventListener("error", () => fail(new Error("Could not connect to the browser.")), { once: true });
|
|
5494
|
+
});
|
|
5495
|
+
socket.addEventListener("message", (event) => {
|
|
5496
|
+
const message = JSON.parse(String(event.data));
|
|
5497
|
+
if (message.id && pending.has(message.id)) {
|
|
5498
|
+
const { done, fail } = pending.get(message.id);
|
|
5499
|
+
pending.delete(message.id);
|
|
5500
|
+
if (message.error) fail(new Error(message.error.message));
|
|
5501
|
+
else done(message.result ?? {});
|
|
5502
|
+
}
|
|
5503
|
+
if (message.method === "Page.loadEventFired") {
|
|
5504
|
+
loaded.fired = true;
|
|
5505
|
+
for (const waiter of loaded.waiters.splice(0)) waiter();
|
|
5506
|
+
}
|
|
5507
|
+
});
|
|
5508
|
+
const send = (method, params = {}, sessionId) => new Promise((done, fail) => {
|
|
5509
|
+
const id = nextId++;
|
|
5510
|
+
pending.set(id, { done, fail });
|
|
5511
|
+
socket.send(JSON.stringify({ id, method, params, sessionId }));
|
|
5512
|
+
setTimeout(() => {
|
|
5513
|
+
if (pending.delete(id)) fail(new Error(`${method} timed out.`));
|
|
5514
|
+
}, timeoutMs);
|
|
5515
|
+
});
|
|
5516
|
+
try {
|
|
5517
|
+
const target = await send("Target.createTarget", { url: "about:blank" });
|
|
5518
|
+
const attached = await send("Target.attachToTarget", { targetId: target.targetId, flatten: true });
|
|
5519
|
+
const session = attached.sessionId;
|
|
5520
|
+
await send("Page.enable", {}, session);
|
|
5521
|
+
await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false }, session);
|
|
5522
|
+
await send("Page.navigate", { url }, session);
|
|
5523
|
+
if (!loaded.fired) {
|
|
5524
|
+
await Promise.race([
|
|
5525
|
+
new Promise((done) => loaded.waiters.push(done)),
|
|
5526
|
+
new Promise((done) => setTimeout(done, timeoutMs / 2))
|
|
5527
|
+
]);
|
|
5528
|
+
}
|
|
5529
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
5530
|
+
const result = await send("Runtime.evaluate", { expression: PROBE_SCRIPT, awaitPromise: true, returnByValue: true }, session);
|
|
5531
|
+
const value = result.result?.value;
|
|
5532
|
+
if (typeof value !== "string") {
|
|
5533
|
+
const description = result.exceptionDetails?.text;
|
|
5534
|
+
throw new Error(`The probe returned nothing${description ? `: ${description}` : ""}. Is ${url} a page this browser can open?`);
|
|
5535
|
+
}
|
|
5536
|
+
return value;
|
|
5537
|
+
} finally {
|
|
5538
|
+
socket.close();
|
|
5539
|
+
}
|
|
5540
|
+
}
|
|
5541
|
+
|
|
5031
5542
|
// src/probe/save.ts
|
|
5032
5543
|
function saveProbe(opts) {
|
|
5033
5544
|
let probe;
|
|
@@ -5049,9 +5560,9 @@ function saveProbe(opts) {
|
|
|
5049
5560
|
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
5050
5561
|
probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
|
|
5051
5562
|
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5052
|
-
const dir =
|
|
5563
|
+
const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5053
5564
|
mkdirSync7(dir, { recursive: true });
|
|
5054
|
-
const path =
|
|
5565
|
+
const path = join30(dir, `probe-${width}.json`);
|
|
5055
5566
|
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
5056
5567
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
5057
5568
|
}
|
|
@@ -5065,7 +5576,49 @@ function pageFile(projectRoot, url) {
|
|
|
5065
5576
|
}
|
|
5066
5577
|
const abs = resolve3(path);
|
|
5067
5578
|
if (!abs.startsWith(resolve3(projectRoot))) return void 0;
|
|
5068
|
-
return
|
|
5579
|
+
return existsSync17(abs) ? abs : void 0;
|
|
5580
|
+
}
|
|
5581
|
+
var PROBE_WIDTHS = [360, 768, 1280, 1600];
|
|
5582
|
+
async function runAndSaveProbes(opts) {
|
|
5583
|
+
const abs = resolve3(opts.projectRoot, opts.page);
|
|
5584
|
+
if (!existsSync17(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
|
|
5585
|
+
const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
|
|
5586
|
+
const saved = [];
|
|
5587
|
+
for (const width of opts.widths ?? PROBE_WIDTHS) {
|
|
5588
|
+
const json = await runProbe({ url, width });
|
|
5589
|
+
saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json }));
|
|
5590
|
+
}
|
|
5591
|
+
return saved;
|
|
5592
|
+
}
|
|
5593
|
+
async function ensureProbes(opts) {
|
|
5594
|
+
const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5595
|
+
const abs = resolve3(opts.projectRoot, opts.page);
|
|
5596
|
+
let current;
|
|
5597
|
+
try {
|
|
5598
|
+
current = checksum(readFileSync23(abs, "utf8"));
|
|
5599
|
+
} catch {
|
|
5600
|
+
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
5601
|
+
}
|
|
5602
|
+
const missing = PROBE_WIDTHS.filter((width) => {
|
|
5603
|
+
try {
|
|
5604
|
+
const probe = JSON.parse(readFileSync23(join30(dir, `probe-${width}.json`), "utf8"));
|
|
5605
|
+
return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
|
|
5606
|
+
} catch {
|
|
5607
|
+
return true;
|
|
5608
|
+
}
|
|
5609
|
+
});
|
|
5610
|
+
if (missing.length === 0) return { recorded: [] };
|
|
5611
|
+
if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
|
|
5612
|
+
const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
|
|
5613
|
+
for (const width of missing) {
|
|
5614
|
+
saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json: await runProbe({ url, width }) });
|
|
5615
|
+
}
|
|
5616
|
+
return { recorded: missing };
|
|
5617
|
+
}
|
|
5618
|
+
function critiquedSurfaces(projectRoot) {
|
|
5619
|
+
const root = join30(projectRoot, ".jig", "critique");
|
|
5620
|
+
if (!existsSync17(root)) return [];
|
|
5621
|
+
return readdirSync12(root).filter((surface) => existsSync17(join30(root, surface, "screen.json")) || existsSync17(join30(root, surface, "code.json")));
|
|
5069
5622
|
}
|
|
5070
5623
|
|
|
5071
5624
|
// src/probe/check.ts
|
|
@@ -5087,11 +5640,11 @@ function stampProblem(projectRoot, file, p) {
|
|
|
5087
5640
|
return void 0;
|
|
5088
5641
|
}
|
|
5089
5642
|
function readProbes(projectRoot, dir, errors) {
|
|
5090
|
-
if (!
|
|
5643
|
+
if (!existsSync18(dir)) return [];
|
|
5091
5644
|
const probes = [];
|
|
5092
|
-
for (const f of
|
|
5645
|
+
for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
5093
5646
|
try {
|
|
5094
|
-
const p = JSON.parse(readFileSync24(
|
|
5647
|
+
const p = JSON.parse(readFileSync24(join31(dir, f), "utf8"));
|
|
5095
5648
|
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
5096
5649
|
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
5097
5650
|
continue;
|
|
@@ -5108,7 +5661,9 @@ function readProbes(projectRoot, dir, errors) {
|
|
|
5108
5661
|
}
|
|
5109
5662
|
return probes;
|
|
5110
5663
|
}
|
|
5111
|
-
|
|
5664
|
+
var TITLE_BUDGET2 = 60;
|
|
5665
|
+
var DESCRIPTION_BUDGET2 = 155;
|
|
5666
|
+
function probeContradictions(probes, verdictOf, indexable = true) {
|
|
5112
5667
|
const errors = [];
|
|
5113
5668
|
const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
|
|
5114
5669
|
const at = (p) => `probe-${p.width}.json`;
|
|
@@ -5122,6 +5677,29 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5122
5677
|
if (p.unresolvedTokens.length) {
|
|
5123
5678
|
errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
|
|
5124
5679
|
}
|
|
5680
|
+
for (const inv of p.orderInversions ?? []) {
|
|
5681
|
+
errors.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
|
|
5682
|
+
}
|
|
5683
|
+
if (p.emDashes?.length) {
|
|
5684
|
+
errors.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
|
|
5685
|
+
}
|
|
5686
|
+
const line = p.longestLine;
|
|
5687
|
+
if (line && line.chars > 90 && clean("B-11")) {
|
|
5688
|
+
errors.push(`B-11 is "${verdictOf("B-11")}", but ${at(p)} measured a line of about ${line.chars} characters ("${line.text}\u2026") \u2014 past every mode's measure. Cap prose at \`--measure-prose\`.`);
|
|
5689
|
+
}
|
|
5690
|
+
const head = p.head;
|
|
5691
|
+
if (head && p.width === probes[0]?.width) {
|
|
5692
|
+
const noindex = /noindex/i.test(head.robots);
|
|
5693
|
+
if (indexable && !noindex) {
|
|
5694
|
+
if (!head.title) errors.push(`${at(p)}: the page served no <title> (J-121) \u2014 a search result then shows a truncated URL.`);
|
|
5695
|
+
else if (head.title.length > TITLE_BUDGET2) errors.push(`${at(p)}: the title served is ${head.title.length} characters, past the ${TITLE_BUDGET2} a search result shows (J-122): "${head.title}".`);
|
|
5696
|
+
if (!head.description) errors.push(`${at(p)}: the page served no meta description (J-121) \u2014 the search engine writes one from whatever text it finds first, usually the navigation.`);
|
|
5697
|
+
else if (head.description.length > DESCRIPTION_BUDGET2) errors.push(`${at(p)}: the description served is ${head.description.length} characters, past the ${DESCRIPTION_BUDGET2} (J-122).`);
|
|
5698
|
+
}
|
|
5699
|
+
if (!indexable && !noindex) {
|
|
5700
|
+
errors.push(`${at(p)}: this page is not indexable, and the page served no noindex (J-123). robots.txt is public and advisory, and is not this.`);
|
|
5701
|
+
}
|
|
5702
|
+
}
|
|
5125
5703
|
if (p.junkText.length) {
|
|
5126
5704
|
errors.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
|
|
5127
5705
|
}
|
|
@@ -5157,22 +5735,64 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5157
5735
|
return errors;
|
|
5158
5736
|
}
|
|
5159
5737
|
|
|
5738
|
+
// src/check/decisions.ts
|
|
5739
|
+
import { existsSync as existsSync19, readFileSync as readFileSync25 } from "fs";
|
|
5740
|
+
import { join as join32 } from "path";
|
|
5741
|
+
var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
|
|
5742
|
+
var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
|
|
5743
|
+
function decisionsFile(projectRoot) {
|
|
5744
|
+
for (const candidate of ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"]) {
|
|
5745
|
+
if (existsSync19(join32(projectRoot, candidate))) return candidate;
|
|
5746
|
+
}
|
|
5747
|
+
return void 0;
|
|
5748
|
+
}
|
|
5749
|
+
function decisionNames(projectRoot) {
|
|
5750
|
+
const path = decisionsFile(projectRoot);
|
|
5751
|
+
if (!path) return [];
|
|
5752
|
+
let body;
|
|
5753
|
+
try {
|
|
5754
|
+
body = readFileSync25(join32(projectRoot, path), "utf8");
|
|
5755
|
+
} catch {
|
|
5756
|
+
return [];
|
|
5757
|
+
}
|
|
5758
|
+
const names = [];
|
|
5759
|
+
for (const match of body.matchAll(HEADING3)) {
|
|
5760
|
+
const name = match[2].replace(/[`*]/g, "").trim();
|
|
5761
|
+
if (!name || NOT_A_DECISION.test(name)) continue;
|
|
5762
|
+
if (!names.includes(name)) names.push(name);
|
|
5763
|
+
}
|
|
5764
|
+
return names;
|
|
5765
|
+
}
|
|
5766
|
+
|
|
5160
5767
|
// src/commands/verdicts.ts
|
|
5161
5768
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
5162
5769
|
var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
|
|
5163
5770
|
function readJson(path, errors) {
|
|
5164
|
-
if (!
|
|
5771
|
+
if (!existsSync20(path)) return null;
|
|
5165
5772
|
try {
|
|
5166
|
-
return JSON.parse(
|
|
5773
|
+
return JSON.parse(readFileSync26(path, "utf8"));
|
|
5167
5774
|
} catch (e) {
|
|
5168
5775
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
5169
5776
|
return {};
|
|
5170
5777
|
}
|
|
5171
5778
|
}
|
|
5779
|
+
function specIndexable(projectRoot, surface) {
|
|
5780
|
+
const path = join33(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5781
|
+
let front = "";
|
|
5782
|
+
try {
|
|
5783
|
+
front = readFileSync26(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5784
|
+
} catch {
|
|
5785
|
+
}
|
|
5786
|
+
const declared = /^\s*indexable\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
|
|
5787
|
+
if (declared === "true" || declared === "yes") return true;
|
|
5788
|
+
if (declared === "false" || declared === "no") return false;
|
|
5789
|
+
const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
|
|
5790
|
+
return mode !== "product" && mode !== "operator";
|
|
5791
|
+
}
|
|
5172
5792
|
function specNeedsNav(projectRoot, surface) {
|
|
5173
|
-
const path =
|
|
5174
|
-
if (!
|
|
5175
|
-
const front =
|
|
5793
|
+
const path = join33(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5794
|
+
if (!existsSync20(path)) return false;
|
|
5795
|
+
const front = readFileSync26(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5176
5796
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
5177
5797
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
5178
5798
|
return navField || navRegion;
|
|
@@ -5220,10 +5840,55 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
5220
5840
|
const judged = total - missing.length;
|
|
5221
5841
|
return { state: missing.length ? "incomplete" : "ran", judged, total, findings };
|
|
5222
5842
|
}
|
|
5843
|
+
function checkDecisions(projectRoot, dir, errors) {
|
|
5844
|
+
const required = decisionNames(projectRoot);
|
|
5845
|
+
if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
|
|
5846
|
+
const file = readJson(join33(dir, "decisions.json"), errors);
|
|
5847
|
+
if (!file) {
|
|
5848
|
+
errors.push(
|
|
5849
|
+
`decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
|
|
5850
|
+
);
|
|
5851
|
+
return { state: "missing", judged: 0, total: required.length, findings: 0 };
|
|
5852
|
+
}
|
|
5853
|
+
const list = Array.isArray(file.verdicts) ? file.verdicts : [];
|
|
5854
|
+
if (!Array.isArray(file.verdicts)) errors.push('decisions.json has no "verdicts" array.');
|
|
5855
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5856
|
+
let findings = 0;
|
|
5857
|
+
for (const v of list) {
|
|
5858
|
+
const name = typeof v.decision === "string" ? v.decision.trim() : "";
|
|
5859
|
+
if (!name) {
|
|
5860
|
+
errors.push("decisions.json: a verdict names no decision.");
|
|
5861
|
+
continue;
|
|
5862
|
+
}
|
|
5863
|
+
const match = required.find((r) => r.toLowerCase() === name.toLowerCase());
|
|
5864
|
+
if (!match) {
|
|
5865
|
+
errors.push(`decisions.json: "${name}" is not a heading in DECISIONS.md. Judge the decisions the project made, not ones you name yourself.`);
|
|
5866
|
+
continue;
|
|
5867
|
+
}
|
|
5868
|
+
if (seen.has(match)) {
|
|
5869
|
+
errors.push(`decisions.json: "${match}" is judged more than once.`);
|
|
5870
|
+
continue;
|
|
5871
|
+
}
|
|
5872
|
+
seen.add(match);
|
|
5873
|
+
if (typeof v.verdict !== "string" || !VERDICTS.includes(v.verdict)) {
|
|
5874
|
+
errors.push(`decisions.json: "${match}" has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
|
|
5875
|
+
continue;
|
|
5876
|
+
}
|
|
5877
|
+
const reason = typeof v.reason === "string" ? v.reason.trim() : "";
|
|
5878
|
+
if (!reason) errors.push(`decisions.json: "${match}" has no reason. Name what on the page satisfies it, or what does not.`);
|
|
5879
|
+
else if (ABSENCE.test(reason)) errors.push(`decisions.json: "${match}" \u2014 "${reason}" says the decision was not read.`);
|
|
5880
|
+
if (v.verdict === "finding") findings++;
|
|
5881
|
+
}
|
|
5882
|
+
const missing = required.filter((r) => !seen.has(r));
|
|
5883
|
+
if (missing.length) {
|
|
5884
|
+
errors.push(`decisions.json: ${missing.length} of ${required.length} decisions have no verdict: ${missing.join(", ")}.`);
|
|
5885
|
+
}
|
|
5886
|
+
return { state: missing.length ? "incomplete" : "ran", judged: required.length - missing.length, total: required.length, findings };
|
|
5887
|
+
}
|
|
5223
5888
|
function verifyVerdicts(opts) {
|
|
5224
5889
|
const root = opts.packageRoot ?? assetRoot();
|
|
5225
5890
|
const errors = [];
|
|
5226
|
-
const index = JSON.parse(
|
|
5891
|
+
const index = JSON.parse(readFileSync26(join33(root, "rules.index.json"), "utf8"));
|
|
5227
5892
|
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
5228
5893
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
5229
5894
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -5232,16 +5897,16 @@ function verifyVerdicts(opts) {
|
|
|
5232
5897
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
5233
5898
|
]);
|
|
5234
5899
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
5235
|
-
const dir =
|
|
5236
|
-
const screenFile = readJson(
|
|
5237
|
-
const codeFile = readJson(
|
|
5900
|
+
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5901
|
+
const screenFile = readJson(join33(dir, "screen.json"), errors);
|
|
5902
|
+
const codeFile = readJson(join33(dir, "code.json"), errors);
|
|
5238
5903
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
5239
5904
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
5240
5905
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
5241
5906
|
let rendered = false;
|
|
5242
5907
|
if (screenFile && screenFile.rendered === true) {
|
|
5243
5908
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
5244
|
-
const absent = artefacts.filter((a) => !
|
|
5909
|
+
const absent = artefacts.filter((a) => !existsSync20(resolve4(opts.projectRoot, a)));
|
|
5245
5910
|
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
5246
5911
|
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
5247
5912
|
else rendered = true;
|
|
@@ -5249,7 +5914,7 @@ function verifyVerdicts(opts) {
|
|
|
5249
5914
|
const probes = readProbes(opts.projectRoot, dir, errors);
|
|
5250
5915
|
if (screenFile && screenFile.rendered === true) {
|
|
5251
5916
|
const widths = new Set(probes.map((p) => p.width));
|
|
5252
|
-
const missing =
|
|
5917
|
+
const missing = PROBE_WIDTHS.filter((w) => !widths.has(w));
|
|
5253
5918
|
if (missing.length) {
|
|
5254
5919
|
errors.push(`screen.json says rendered: true, but there is no probe at ${missing.join(", ")}px. At each width run \`jig probe\` in the browser and save its output as probe-<width>.json.`);
|
|
5255
5920
|
rendered = false;
|
|
@@ -5260,28 +5925,29 @@ function verifyVerdicts(opts) {
|
|
|
5260
5925
|
const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
|
|
5261
5926
|
return typeof v?.verdict === "string" ? v.verdict : void 0;
|
|
5262
5927
|
};
|
|
5263
|
-
errors.push(...probeContradictions(probes, verdictOf));
|
|
5928
|
+
errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
|
|
5264
5929
|
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
5930
|
+
const decisions = checkDecisions(opts.projectRoot, dir, errors);
|
|
5265
5931
|
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
5266
|
-
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings}`;
|
|
5267
|
-
return { ok: errors.length === 0, errors, screen, code, rendered, line };
|
|
5932
|
+
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}`;
|
|
5933
|
+
return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line };
|
|
5268
5934
|
}
|
|
5269
5935
|
|
|
5270
5936
|
// src/commands/gate.ts
|
|
5271
|
-
import { existsSync as
|
|
5937
|
+
import { existsSync as existsSync22, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync28, writeFileSync as writeFileSync7 } from "fs";
|
|
5272
5938
|
import { createHash as createHash2 } from "crypto";
|
|
5273
|
-
import { join as
|
|
5939
|
+
import { join as join35 } from "path";
|
|
5274
5940
|
|
|
5275
5941
|
// src/check/spec-shape.ts
|
|
5276
|
-
import { existsSync as
|
|
5277
|
-
import { join as
|
|
5942
|
+
import { existsSync as existsSync21, readFileSync as readFileSync27, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
|
|
5943
|
+
import { join as join34 } from "path";
|
|
5278
5944
|
function newestSpec(projectRoot) {
|
|
5279
|
-
const dir =
|
|
5280
|
-
if (!
|
|
5281
|
-
const files =
|
|
5945
|
+
const dir = join34(projectRoot, ".jig", "specs");
|
|
5946
|
+
if (!existsSync21(dir)) return void 0;
|
|
5947
|
+
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
5282
5948
|
if (files.length === 0) return void 0;
|
|
5283
|
-
const newest = files.map((f) => ({ f, at: statSync2(
|
|
5284
|
-
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body:
|
|
5949
|
+
const newest = files.map((f) => ({ f, at: statSync2(join34(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
5950
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync27(join34(dir, newest), "utf8") };
|
|
5285
5951
|
}
|
|
5286
5952
|
function specProblems(spec) {
|
|
5287
5953
|
const parts = spec.body.split(/^---\s*$/m);
|
|
@@ -5295,7 +5961,7 @@ function specProblems(spec) {
|
|
|
5295
5961
|
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
5296
5962
|
}
|
|
5297
5963
|
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
5298
|
-
for (const size of ["phone", "tablet", "desktop"]) {
|
|
5964
|
+
for (const size of ["phone", "tablet", "desktop", "wide"]) {
|
|
5299
5965
|
const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
|
|
5300
5966
|
if (!line) {
|
|
5301
5967
|
problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
|
|
@@ -5336,7 +6002,7 @@ function navProblems(spec) {
|
|
|
5336
6002
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
5337
6003
|
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
5338
6004
|
const problems = [];
|
|
5339
|
-
for (const size of ["tablet", "desktop"]) {
|
|
6005
|
+
for (const size of ["tablet", "landscape", "desktop", "wide"]) {
|
|
5340
6006
|
const block = sizeBlock(sizes, size);
|
|
5341
6007
|
const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
|
|
5342
6008
|
if (!nav || NONE_RE.test(nav)) continue;
|
|
@@ -5350,10 +6016,10 @@ function navProblems(spec) {
|
|
|
5350
6016
|
// src/commands/gate.ts
|
|
5351
6017
|
var MAX_BLOCKS = 3;
|
|
5352
6018
|
function lastJigCommand(transcriptPath) {
|
|
5353
|
-
if (!transcriptPath || !
|
|
6019
|
+
if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
|
|
5354
6020
|
let text;
|
|
5355
6021
|
try {
|
|
5356
|
-
text =
|
|
6022
|
+
text = readFileSync28(transcriptPath, "utf8");
|
|
5357
6023
|
} catch {
|
|
5358
6024
|
return void 0;
|
|
5359
6025
|
}
|
|
@@ -5371,10 +6037,10 @@ function commandProblems(root, command) {
|
|
|
5371
6037
|
const problems = [];
|
|
5372
6038
|
const spec = newestSpec(root);
|
|
5373
6039
|
if (command === "decide") {
|
|
5374
|
-
const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) =>
|
|
6040
|
+
const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join35(root, p)).find((p) => existsSync22(p));
|
|
5375
6041
|
if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
5376
6042
|
else {
|
|
5377
|
-
const body =
|
|
6043
|
+
const body = readFileSync28(file, "utf8");
|
|
5378
6044
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
5379
6045
|
problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
|
|
5380
6046
|
}
|
|
@@ -5395,15 +6061,18 @@ function commandProblems(root, command) {
|
|
|
5395
6061
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
5396
6062
|
if (/^approved/i.test(mockup)) {
|
|
5397
6063
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
5398
|
-
else if (!/^https?:/i.test(at) && !
|
|
6064
|
+
else if (!/^https?:/i.test(at) && !existsSync22(join35(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
5399
6065
|
else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
|
|
5400
6066
|
}
|
|
5401
6067
|
}
|
|
5402
6068
|
if (command === "critique") {
|
|
5403
|
-
const dir =
|
|
5404
|
-
const surfaces =
|
|
6069
|
+
const dir = join35(root, ".jig", "critique");
|
|
6070
|
+
const surfaces = existsSync22(dir) ? readdirSync15(dir).filter((s) => existsSync22(join35(dir, s, "screen.json")) || existsSync22(join35(dir, s, "code.json"))) : [];
|
|
5405
6071
|
for (const surface of surfaces) {
|
|
5406
6072
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6073
|
+
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
6074
|
+
problems.push(`${surface}: ${v.decisions.total - v.decisions.judged} of ${v.decisions.total} decisions in DECISIONS.md have no verdict. A page can satisfy every rule and still break what this project decided.`);
|
|
6075
|
+
}
|
|
5407
6076
|
if (v.ok && v.screen.state === "skipped") {
|
|
5408
6077
|
problems.push(`${surface}: the screen pass judged ${v.screen.judged} rules with rendered: false. Those rules are judged on a render \u2014 open the page at 360, 768 and 1280, run \`jig probe\` at each, and judge them there.`);
|
|
5409
6078
|
}
|
|
@@ -5414,9 +6083,19 @@ function commandProblems(root, command) {
|
|
|
5414
6083
|
}
|
|
5415
6084
|
return problems;
|
|
5416
6085
|
}
|
|
6086
|
+
function surfacePage(projectRoot, surface) {
|
|
6087
|
+
const spec = newestSpec(projectRoot);
|
|
6088
|
+
const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
|
|
6089
|
+
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
6090
|
+
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
6091
|
+
for (const candidate of candidates) {
|
|
6092
|
+
if (candidate && existsSync22(join35(projectRoot, candidate))) return candidate;
|
|
6093
|
+
}
|
|
6094
|
+
return void 0;
|
|
6095
|
+
}
|
|
5417
6096
|
function gate(opts) {
|
|
5418
6097
|
const root = opts.projectRoot;
|
|
5419
|
-
if (!
|
|
6098
|
+
if (!existsSync22(join35(root, "jig.config.json")) && !existsSync22(join35(root, ".jig"))) {
|
|
5420
6099
|
return { block: false, reason: "" };
|
|
5421
6100
|
}
|
|
5422
6101
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -5435,11 +6114,11 @@ function gate(opts) {
|
|
|
5435
6114
|
);
|
|
5436
6115
|
}
|
|
5437
6116
|
}
|
|
5438
|
-
const critiqueDir =
|
|
5439
|
-
if (
|
|
5440
|
-
for (const surface of
|
|
5441
|
-
const dir =
|
|
5442
|
-
if (!
|
|
6117
|
+
const critiqueDir = join35(root, ".jig", "critique");
|
|
6118
|
+
if (existsSync22(critiqueDir)) {
|
|
6119
|
+
for (const surface of readdirSync15(critiqueDir)) {
|
|
6120
|
+
const dir = join35(critiqueDir, surface);
|
|
6121
|
+
if (!existsSync22(join35(dir, "screen.json")) && !existsSync22(join35(dir, "code.json"))) continue;
|
|
5443
6122
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5444
6123
|
if (!v.ok) {
|
|
5445
6124
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -5453,10 +6132,10 @@ function gate(opts) {
|
|
|
5453
6132
|
}
|
|
5454
6133
|
const session = opts.input.session_id ?? "unknown";
|
|
5455
6134
|
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
5456
|
-
const stateFile =
|
|
6135
|
+
const stateFile = join35(root, ".jig", "gate.json");
|
|
5457
6136
|
let state = {};
|
|
5458
6137
|
try {
|
|
5459
|
-
state = JSON.parse(
|
|
6138
|
+
state = JSON.parse(readFileSync28(stateFile, "utf8"));
|
|
5460
6139
|
} catch {
|
|
5461
6140
|
}
|
|
5462
6141
|
if (problems.length === 0) {
|
|
@@ -5486,15 +6165,94 @@ ${problems.join("\n\n")}`
|
|
|
5486
6165
|
}
|
|
5487
6166
|
function save(file, state) {
|
|
5488
6167
|
try {
|
|
5489
|
-
mkdirSync8(
|
|
6168
|
+
mkdirSync8(join35(file, ".."), { recursive: true });
|
|
5490
6169
|
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
5491
6170
|
} catch {
|
|
5492
6171
|
}
|
|
5493
6172
|
}
|
|
5494
6173
|
|
|
6174
|
+
// src/commands/seo.ts
|
|
6175
|
+
import { readFileSync as readFileSync29 } from "fs";
|
|
6176
|
+
import { join as join36 } from "path";
|
|
6177
|
+
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
6178
|
+
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
6179
|
+
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
6180
|
+
function routeOf(file) {
|
|
6181
|
+
const path = file.replace(/^(src|app|pages|public|content|routes)\//, "").replace(/(^|\/)(index|page)\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "$1").replace(/\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "").replace(/\/$/, "");
|
|
6182
|
+
return `/${path}`.replace(/\/+/g, "/");
|
|
6183
|
+
}
|
|
6184
|
+
function seo(opts) {
|
|
6185
|
+
const root = opts.projectRoot;
|
|
6186
|
+
const files = selectFiles(root, true).files;
|
|
6187
|
+
const findings = [];
|
|
6188
|
+
const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
|
|
6189
|
+
const pages = [];
|
|
6190
|
+
let sitemapFiles = [];
|
|
6191
|
+
let robotsFiles = [];
|
|
6192
|
+
const sitemapRoutes = /* @__PURE__ */ new Map();
|
|
6193
|
+
for (const file of files) {
|
|
6194
|
+
let raw;
|
|
6195
|
+
try {
|
|
6196
|
+
raw = readFileSync29(join36(root, file), "utf8");
|
|
6197
|
+
} catch {
|
|
6198
|
+
continue;
|
|
6199
|
+
}
|
|
6200
|
+
if (SITEMAP_FILE.test(file)) {
|
|
6201
|
+
sitemapFiles.push(file);
|
|
6202
|
+
for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
|
|
6203
|
+
const value = (match[1] ?? match[2] ?? "").trim();
|
|
6204
|
+
if (!value) continue;
|
|
6205
|
+
const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
|
|
6206
|
+
sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
|
|
6207
|
+
}
|
|
6208
|
+
continue;
|
|
6209
|
+
}
|
|
6210
|
+
if (ROBOTS_FILE.test(file)) {
|
|
6211
|
+
robotsFiles.push(file);
|
|
6212
|
+
continue;
|
|
6213
|
+
}
|
|
6214
|
+
if (!isReaderText(file)) continue;
|
|
6215
|
+
const found = readMetadata(raw);
|
|
6216
|
+
if (!found.declares && !isWholeDocument(raw)) continue;
|
|
6217
|
+
const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
|
|
6218
|
+
pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
|
|
6219
|
+
}
|
|
6220
|
+
for (const page of pages.filter((p) => p.noindex)) {
|
|
6221
|
+
const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
|
|
6222
|
+
if (listed) {
|
|
6223
|
+
add(listed, "J-124", "error", `the sitemap lists ${page.route}, and ${page.file} says noindex. One answer per route: a crawler reading two picks one.`);
|
|
6224
|
+
}
|
|
6225
|
+
}
|
|
6226
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
6227
|
+
for (const page of pages) {
|
|
6228
|
+
if (!page.title || page.noindex) continue;
|
|
6229
|
+
byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
|
|
6230
|
+
}
|
|
6231
|
+
for (const [title, where] of byTitle) {
|
|
6232
|
+
if (where.length > 1) {
|
|
6233
|
+
add(where[1], "J-121", "warning", `"${title}" is the title of ${where.length} pages (${where.join(", ")}). A result page showing the same line twice tells a reader nothing about either.`);
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
const indexable = pages.filter((p) => !p.noindex);
|
|
6237
|
+
const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
|
|
6238
|
+
if (publicSurface && indexable.length > 0) {
|
|
6239
|
+
if (sitemapFiles.length === 0) {
|
|
6240
|
+
add(".", "J-124", "warning", `${indexable.length} indexable page(s) and no sitemap. A crawler then finds what it happens to link to.`);
|
|
6241
|
+
} else if (sitemapRoutes.size === 0) {
|
|
6242
|
+
add(sitemapFiles[0], "J-127", "error", "the sitemap lists no routes. An empty sitemap is not a missing one: it is a claim that the site has nothing.");
|
|
6243
|
+
}
|
|
6244
|
+
if (robotsFiles.length === 0) {
|
|
6245
|
+
add(".", "J-123", "note", "no robots file. Nothing is blocked by its absence, but nothing points at the sitemap either.");
|
|
6246
|
+
}
|
|
6247
|
+
}
|
|
6248
|
+
const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
|
|
6249
|
+
const line = `JIG_SEO: pages=${pages.length} indexable=${indexable.length} noindex=${pages.length - indexable.length} sitemap=${sitemapFiles.length > 0 ? sitemapRoutes.size : "none"} robots=${robotsFiles.length > 0 ? "yes" : "no"} overlong=${titleBudget} findings=${findings.length}`;
|
|
6250
|
+
return { findings, pages: pages.length, indexable: indexable.length, line };
|
|
6251
|
+
}
|
|
6252
|
+
|
|
5495
6253
|
// src/index.ts
|
|
5496
6254
|
var packageRoot = getPackageRoot();
|
|
5497
|
-
var { version } = JSON.parse(
|
|
6255
|
+
var { version } = JSON.parse(readFileSync30(join37(packageRoot, "package.json"), "utf8"));
|
|
5498
6256
|
function warnIfUnpublishedPin() {
|
|
5499
6257
|
if (isPublishedBuild(packageRoot)) return;
|
|
5500
6258
|
console.warn(
|
|
@@ -5532,7 +6290,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
5532
6290
|
projectRoot,
|
|
5533
6291
|
packageRoot: assetRoot(),
|
|
5534
6292
|
version,
|
|
5535
|
-
homeDir:
|
|
6293
|
+
homeDir: homedir2(),
|
|
5536
6294
|
hook
|
|
5537
6295
|
});
|
|
5538
6296
|
if (result.warning) {
|
|
@@ -5570,7 +6328,7 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
5570
6328
|
projectRoot,
|
|
5571
6329
|
packageRoot: assetRoot(),
|
|
5572
6330
|
version,
|
|
5573
|
-
homeDir:
|
|
6331
|
+
homeDir: homedir2()
|
|
5574
6332
|
});
|
|
5575
6333
|
const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
|
|
5576
6334
|
warnIfUnpublishedPin();
|
|
@@ -5608,7 +6366,7 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
5608
6366
|
try {
|
|
5609
6367
|
const result = check({
|
|
5610
6368
|
projectRoot,
|
|
5611
|
-
homeDir:
|
|
6369
|
+
homeDir: homedir2(),
|
|
5612
6370
|
version,
|
|
5613
6371
|
all: opts.all,
|
|
5614
6372
|
ci: opts.ci
|
|
@@ -5620,7 +6378,41 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
5620
6378
|
process.exit(1);
|
|
5621
6379
|
}
|
|
5622
6380
|
});
|
|
5623
|
-
program.command("
|
|
6381
|
+
program.command("seo").description("Audit what a search engine and a link preview read, across the whole project.").option("--json", "emit findings as JSON", false).action((opts) => {
|
|
6382
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
6383
|
+
try {
|
|
6384
|
+
const result = seo({ projectRoot, mode: resolveSurfaceMode(projectRoot) });
|
|
6385
|
+
if (opts.json) {
|
|
6386
|
+
console.log(JSON.stringify(result.findings, null, 2));
|
|
6387
|
+
} else {
|
|
6388
|
+
const symbol = { error: "\u2717", warning: "\u26A0", note: "\xB7" };
|
|
6389
|
+
for (const f of result.findings) console.log(` ${symbol[f.severity]} ${f.ruleId} ${f.message} ${f.file}`);
|
|
6390
|
+
if (result.findings.length === 0) console.log(" Nothing to fix in what a search engine reads.");
|
|
6391
|
+
console.log(` ${result.line}`);
|
|
6392
|
+
}
|
|
6393
|
+
process.exit(result.findings.some((f) => f.severity === "error") ? 1 : 0);
|
|
6394
|
+
} catch (err) {
|
|
6395
|
+
console.error(err.message);
|
|
6396
|
+
process.exit(1);
|
|
6397
|
+
}
|
|
6398
|
+
});
|
|
6399
|
+
program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768 and 1280, and record each (needs --save)").action(async (opts) => {
|
|
6400
|
+
if (opts.run) {
|
|
6401
|
+
if (!opts.save) {
|
|
6402
|
+
console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
|
|
6403
|
+
process.exit(1);
|
|
6404
|
+
}
|
|
6405
|
+
const projectRoot2 = findProjectRoot(process.cwd());
|
|
6406
|
+
try {
|
|
6407
|
+
for (const saved of await runAndSaveProbes({ projectRoot: projectRoot2, surface: opts.save, page: opts.run })) {
|
|
6408
|
+
console.log(` Recorded ${saved.path} \u2014 ${saved.page} at ${saved.width}px.`);
|
|
6409
|
+
}
|
|
6410
|
+
} catch (err) {
|
|
6411
|
+
console.error(` \u2717 ${err.message}`);
|
|
6412
|
+
process.exit(1);
|
|
6413
|
+
}
|
|
6414
|
+
return;
|
|
6415
|
+
}
|
|
5624
6416
|
if (!opts.save) {
|
|
5625
6417
|
console.log(PROBE_SCRIPT);
|
|
5626
6418
|
return;
|
|
@@ -5643,15 +6435,24 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
5643
6435
|
process.exit(1);
|
|
5644
6436
|
}
|
|
5645
6437
|
});
|
|
5646
|
-
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(() => {
|
|
6438
|
+
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
5647
6439
|
let input = {};
|
|
5648
6440
|
try {
|
|
5649
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
6441
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync30(0, "utf8") || "{}");
|
|
5650
6442
|
} catch {
|
|
5651
6443
|
}
|
|
5652
6444
|
const cwd = input.cwd ?? process.cwd();
|
|
5653
6445
|
try {
|
|
5654
|
-
const
|
|
6446
|
+
const projectRoot = findProjectRoot(cwd);
|
|
6447
|
+
for (const surface of critiquedSurfaces(projectRoot)) {
|
|
6448
|
+
const page = surfacePage(projectRoot, surface);
|
|
6449
|
+
if (!page) continue;
|
|
6450
|
+
try {
|
|
6451
|
+
await ensureProbes({ projectRoot, surface, page });
|
|
6452
|
+
} catch {
|
|
6453
|
+
}
|
|
6454
|
+
}
|
|
6455
|
+
const result = gate({ projectRoot, version, input });
|
|
5655
6456
|
if (result.block) console.log(JSON.stringify({ decision: "block", reason: result.reason }));
|
|
5656
6457
|
else if (result.reason) console.error(result.reason);
|
|
5657
6458
|
} catch (err) {
|
|
@@ -5665,7 +6466,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
5665
6466
|
await init({
|
|
5666
6467
|
projectRoot,
|
|
5667
6468
|
packageRoot: assetRoot(),
|
|
5668
|
-
homeDir:
|
|
6469
|
+
homeDir: homedir2(),
|
|
5669
6470
|
version,
|
|
5670
6471
|
yes: opts.yes
|
|
5671
6472
|
});
|
|
@@ -5674,4 +6475,13 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
5674
6475
|
process.exit(1);
|
|
5675
6476
|
}
|
|
5676
6477
|
});
|
|
6478
|
+
function resolveSurfaceMode(projectRoot) {
|
|
6479
|
+
try {
|
|
6480
|
+
const config = JSON.parse(readFileSync30(join37(projectRoot, "jig.config.json"), "utf8"));
|
|
6481
|
+
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
6482
|
+
return modes.length === 1 ? modes[0] : void 0;
|
|
6483
|
+
} catch {
|
|
6484
|
+
return void 0;
|
|
6485
|
+
}
|
|
6486
|
+
}
|
|
5677
6487
|
program.parse();
|