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/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 readFileSync28 } from "fs";
6
- import { homedir } from "os";
7
- import { join as join34 } from "path";
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(metadata) {
391
- const rows = Object.entries(metadata).map(([name, meta]) => {
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 metadata = JSON.parse(
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(metadata),
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 metadata = JSON.parse(
542
+ const metadata2 = JSON.parse(
543
543
  readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
544
544
  );
545
- const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
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 MARKUP = [".html", ".htm", ".vue", ".svelte", ".astro", ".jsx", ".tsx", ".php", ".erb", ".twig", ".hbs"];
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
- for (const tag of masked.matchAll(/<[^>]*>/g)) {
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) => hasExtension(file, MARKUP),
3386
+ appliesTo: (file) => isReaderText(file),
3329
3387
  run(_source, file, ctx) {
3330
- const masked = maskNonProse(ctx.raw);
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
- for (const span of proseSpans(masked)) {
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 offset = span.index + span.text.indexOf("\u2014");
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
- if (seen.has(line)) continue;
3339
- seen.add(line);
3340
- findings.push(
3341
- mkFinding(
3342
- ctx,
3343
- "em-dash",
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 existsSync18, readFileSync as readFileSync25 } from "fs";
4938
- import { join as join31, resolve as resolve4 } from "path";
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 existsSync17, readdirSync as readdirSync11, readFileSync as readFileSync24 } from "fs";
4953
- import { join as join30 } from "path";
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 existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
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 join29, relative as relative3, resolve as resolve3 } from "path";
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 = 2;
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 = join29(opts.projectRoot, ".jig", "critique", opts.surface);
5563
+ const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
5053
5564
  mkdirSync7(dir, { recursive: true });
5054
- const path = join29(dir, `probe-${width}.json`);
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 existsSync16(abs) ? abs : void 0;
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 (!existsSync17(dir)) return [];
5643
+ if (!existsSync18(dir)) return [];
5091
5644
  const probes = [];
5092
- for (const f of readdirSync11(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
5645
+ for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
5093
5646
  try {
5094
- const p = JSON.parse(readFileSync24(join30(dir, f), "utf8"));
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
- function probeContradictions(probes, verdictOf) {
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 (!existsSync18(path)) return null;
5771
+ if (!existsSync20(path)) return null;
5165
5772
  try {
5166
- return JSON.parse(readFileSync25(path, "utf8"));
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 = join31(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5174
- if (!existsSync18(path)) return false;
5175
- const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
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(readFileSync25(join31(root, "rules.index.json"), "utf8"));
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 = join31(opts.projectRoot, ".jig", "critique", opts.surface);
5236
- const screenFile = readJson(join31(dir, "screen.json"), errors);
5237
- const codeFile = readJson(join31(dir, "code.json"), errors);
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) => !existsSync18(resolve4(opts.projectRoot, 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 = [360, 768, 1280].filter((w) => !widths.has(w));
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 existsSync20, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
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 join33 } from "path";
5939
+ import { join as join35 } from "path";
5274
5940
 
5275
5941
  // src/check/spec-shape.ts
5276
- import { existsSync as existsSync19, readFileSync as readFileSync26, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
5277
- import { join as join32 } from "path";
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 = join32(projectRoot, ".jig", "specs");
5280
- if (!existsSync19(dir)) return void 0;
5281
- const files = readdirSync12(dir).filter((f) => f.endsWith(".md"));
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(join32(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5284
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join32(dir, newest), "utf8") };
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 || !existsSync20(transcriptPath)) return void 0;
6019
+ if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
5354
6020
  let text;
5355
6021
  try {
5356
- text = readFileSync27(transcriptPath, "utf8");
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) => join33(root, p)).find((p) => existsSync20(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 = readFileSync27(file, "utf8");
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) && !existsSync20(join33(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
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 = join33(root, ".jig", "critique");
5404
- const surfaces = existsSync20(dir) ? readdirSync13(dir).filter((s) => existsSync20(join33(dir, s, "screen.json")) || existsSync20(join33(dir, s, "code.json"))) : [];
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 (!existsSync20(join33(root, "jig.config.json")) && !existsSync20(join33(root, ".jig"))) {
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 = join33(root, ".jig", "critique");
5439
- if (existsSync20(critiqueDir)) {
5440
- for (const surface of readdirSync13(critiqueDir)) {
5441
- const dir = join33(critiqueDir, surface);
5442
- if (!existsSync20(join33(dir, "screen.json")) && !existsSync20(join33(dir, "code.json"))) continue;
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 = join33(root, ".jig", "gate.json");
6135
+ const stateFile = join35(root, ".jig", "gate.json");
5457
6136
  let state = {};
5458
6137
  try {
5459
- state = JSON.parse(readFileSync27(stateFile, "utf8"));
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(join33(file, ".."), { recursive: true });
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(readFileSync28(join34(packageRoot, "package.json"), "utf8"));
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: 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: 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: 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("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>/").action(async (opts) => {
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(readFileSync28(0, "utf8") || "{}");
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 result = gate({ projectRoot: findProjectRoot(cwd), version, input });
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: 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();