jig-ui 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +221 -5
- package/dist/index.js +467 -57
- package/package.json +2 -2
- package/rules/00-anti-patterns.md +101 -0
- package/rules/01-modes.md +1 -1
- package/rules/03-patterns.md +34 -0
- package/rules.index.json +105 -0
- package/templates/COMMAND.md.tmpl +245 -14
- package/templates/command-metadata.json +5 -0
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
|
|
5
|
+
import { readFileSync as readFileSync30 } from "fs";
|
|
6
6
|
import { homedir as homedir2 } from "os";
|
|
7
|
-
import { join as
|
|
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");
|
|
@@ -1823,7 +1823,13 @@ var REPO_DOCUMENTS = /* @__PURE__ */ new Set([
|
|
|
1823
1823
|
"agents",
|
|
1824
1824
|
"claude",
|
|
1825
1825
|
"gemini",
|
|
1826
|
-
"copilot-instructions"
|
|
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"
|
|
1827
1833
|
]);
|
|
1828
1834
|
function isRepoDocument(file) {
|
|
1829
1835
|
const base = file.split("/").pop() ?? file;
|
|
@@ -3473,6 +3479,149 @@ var semanticElement = {
|
|
|
3473
3479
|
}
|
|
3474
3480
|
};
|
|
3475
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
|
+
|
|
3476
3625
|
// src/check/registry.ts
|
|
3477
3626
|
var DETECTORS = [
|
|
3478
3627
|
gradientText,
|
|
@@ -3492,7 +3641,9 @@ var DETECTORS = [
|
|
|
3492
3641
|
menuState,
|
|
3493
3642
|
undeclaredToken,
|
|
3494
3643
|
emDash,
|
|
3495
|
-
semanticElement
|
|
3644
|
+
semanticElement,
|
|
3645
|
+
metadata,
|
|
3646
|
+
interfaceSafety
|
|
3496
3647
|
];
|
|
3497
3648
|
function getDetector(name) {
|
|
3498
3649
|
return DETECTORS.find((d) => d.name === name);
|
|
@@ -5056,8 +5207,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5056
5207
|
}
|
|
5057
5208
|
|
|
5058
5209
|
// src/commands/verdicts.ts
|
|
5059
|
-
import { existsSync as
|
|
5060
|
-
import { join as
|
|
5210
|
+
import { existsSync as existsSync20, readFileSync as readFileSync26 } from "fs";
|
|
5211
|
+
import { join as join33, resolve as resolve4 } from "path";
|
|
5061
5212
|
|
|
5062
5213
|
// src/rules/citations.ts
|
|
5063
5214
|
import { join as join28 } from "path";
|
|
@@ -5080,7 +5231,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
|
|
|
5080
5231
|
import { join as join30, relative as relative3, resolve as resolve3 } from "path";
|
|
5081
5232
|
|
|
5082
5233
|
// src/probe/script.ts
|
|
5083
|
-
var PROBE_VERSION =
|
|
5234
|
+
var PROBE_VERSION = 5;
|
|
5084
5235
|
var PROBE_SCRIPT = `(async () => {
|
|
5085
5236
|
const doc = document.documentElement;
|
|
5086
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'; };
|
|
@@ -5142,6 +5293,20 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5142
5293
|
}
|
|
5143
5294
|
}
|
|
5144
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;
|
|
5145
5310
|
const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
|
|
5146
5311
|
// Measured before anything is clicked: what a reader sees on arrival.
|
|
5147
5312
|
const navAtRest = navLinks();
|
|
@@ -5189,6 +5354,17 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5189
5354
|
emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
|
|
5190
5355
|
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
|
|
5191
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,
|
|
5192
5368
|
landmarks: landmarks.map(describe),
|
|
5193
5369
|
orderInversions: inversions.slice(0, 5),
|
|
5194
5370
|
menu,
|
|
@@ -5402,7 +5578,7 @@ function pageFile(projectRoot, url) {
|
|
|
5402
5578
|
if (!abs.startsWith(resolve3(projectRoot))) return void 0;
|
|
5403
5579
|
return existsSync17(abs) ? abs : void 0;
|
|
5404
5580
|
}
|
|
5405
|
-
var PROBE_WIDTHS = [360, 768, 1280];
|
|
5581
|
+
var PROBE_WIDTHS = [360, 768, 1280, 1600];
|
|
5406
5582
|
async function runAndSaveProbes(opts) {
|
|
5407
5583
|
const abs = resolve3(opts.projectRoot, opts.page);
|
|
5408
5584
|
if (!existsSync17(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
|
|
@@ -5485,7 +5661,9 @@ function readProbes(projectRoot, dir, errors) {
|
|
|
5485
5661
|
}
|
|
5486
5662
|
return probes;
|
|
5487
5663
|
}
|
|
5488
|
-
|
|
5664
|
+
var TITLE_BUDGET2 = 60;
|
|
5665
|
+
var DESCRIPTION_BUDGET2 = 155;
|
|
5666
|
+
function probeContradictions(probes, verdictOf, indexable = true) {
|
|
5489
5667
|
const errors = [];
|
|
5490
5668
|
const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
|
|
5491
5669
|
const at = (p) => `probe-${p.width}.json`;
|
|
@@ -5505,6 +5683,23 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5505
5683
|
if (p.emDashes?.length) {
|
|
5506
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.`);
|
|
5507
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
|
+
}
|
|
5508
5703
|
if (p.junkText.length) {
|
|
5509
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.`);
|
|
5510
5705
|
}
|
|
@@ -5540,22 +5735,64 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5540
5735
|
return errors;
|
|
5541
5736
|
}
|
|
5542
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
|
+
|
|
5543
5767
|
// src/commands/verdicts.ts
|
|
5544
5768
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
5545
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;
|
|
5546
5770
|
function readJson(path, errors) {
|
|
5547
|
-
if (!
|
|
5771
|
+
if (!existsSync20(path)) return null;
|
|
5548
5772
|
try {
|
|
5549
|
-
return JSON.parse(
|
|
5773
|
+
return JSON.parse(readFileSync26(path, "utf8"));
|
|
5550
5774
|
} catch (e) {
|
|
5551
5775
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
5552
5776
|
return {};
|
|
5553
5777
|
}
|
|
5554
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
|
+
}
|
|
5555
5792
|
function specNeedsNav(projectRoot, surface) {
|
|
5556
|
-
const path =
|
|
5557
|
-
if (!
|
|
5558
|
-
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] ?? "";
|
|
5559
5796
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
5560
5797
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
5561
5798
|
return navField || navRegion;
|
|
@@ -5603,10 +5840,55 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
5603
5840
|
const judged = total - missing.length;
|
|
5604
5841
|
return { state: missing.length ? "incomplete" : "ran", judged, total, findings };
|
|
5605
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
|
+
}
|
|
5606
5888
|
function verifyVerdicts(opts) {
|
|
5607
5889
|
const root = opts.packageRoot ?? assetRoot();
|
|
5608
5890
|
const errors = [];
|
|
5609
|
-
const index = JSON.parse(
|
|
5891
|
+
const index = JSON.parse(readFileSync26(join33(root, "rules.index.json"), "utf8"));
|
|
5610
5892
|
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
5611
5893
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
5612
5894
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -5615,16 +5897,16 @@ function verifyVerdicts(opts) {
|
|
|
5615
5897
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
5616
5898
|
]);
|
|
5617
5899
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
5618
|
-
const dir =
|
|
5619
|
-
const screenFile = readJson(
|
|
5620
|
-
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);
|
|
5621
5903
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
5622
5904
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
5623
5905
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
5624
5906
|
let rendered = false;
|
|
5625
5907
|
if (screenFile && screenFile.rendered === true) {
|
|
5626
5908
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
5627
|
-
const absent = artefacts.filter((a) => !
|
|
5909
|
+
const absent = artefacts.filter((a) => !existsSync20(resolve4(opts.projectRoot, a)));
|
|
5628
5910
|
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
5629
5911
|
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
5630
5912
|
else rendered = true;
|
|
@@ -5632,7 +5914,7 @@ function verifyVerdicts(opts) {
|
|
|
5632
5914
|
const probes = readProbes(opts.projectRoot, dir, errors);
|
|
5633
5915
|
if (screenFile && screenFile.rendered === true) {
|
|
5634
5916
|
const widths = new Set(probes.map((p) => p.width));
|
|
5635
|
-
const missing =
|
|
5917
|
+
const missing = PROBE_WIDTHS.filter((w) => !widths.has(w));
|
|
5636
5918
|
if (missing.length) {
|
|
5637
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.`);
|
|
5638
5920
|
rendered = false;
|
|
@@ -5643,28 +5925,29 @@ function verifyVerdicts(opts) {
|
|
|
5643
5925
|
const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
|
|
5644
5926
|
return typeof v?.verdict === "string" ? v.verdict : void 0;
|
|
5645
5927
|
};
|
|
5646
|
-
errors.push(...probeContradictions(probes, verdictOf));
|
|
5928
|
+
errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
|
|
5647
5929
|
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
5930
|
+
const decisions = checkDecisions(opts.projectRoot, dir, errors);
|
|
5648
5931
|
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
5649
|
-
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings}`;
|
|
5650
|
-
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 };
|
|
5651
5934
|
}
|
|
5652
5935
|
|
|
5653
5936
|
// src/commands/gate.ts
|
|
5654
|
-
import { existsSync as
|
|
5937
|
+
import { existsSync as existsSync22, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync28, writeFileSync as writeFileSync7 } from "fs";
|
|
5655
5938
|
import { createHash as createHash2 } from "crypto";
|
|
5656
|
-
import { join as
|
|
5939
|
+
import { join as join35 } from "path";
|
|
5657
5940
|
|
|
5658
5941
|
// src/check/spec-shape.ts
|
|
5659
|
-
import { existsSync as
|
|
5660
|
-
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";
|
|
5661
5944
|
function newestSpec(projectRoot) {
|
|
5662
|
-
const dir =
|
|
5663
|
-
if (!
|
|
5945
|
+
const dir = join34(projectRoot, ".jig", "specs");
|
|
5946
|
+
if (!existsSync21(dir)) return void 0;
|
|
5664
5947
|
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
5665
5948
|
if (files.length === 0) return void 0;
|
|
5666
|
-
const newest = files.map((f) => ({ f, at: statSync2(
|
|
5667
|
-
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") };
|
|
5668
5951
|
}
|
|
5669
5952
|
function specProblems(spec) {
|
|
5670
5953
|
const parts = spec.body.split(/^---\s*$/m);
|
|
@@ -5678,7 +5961,7 @@ function specProblems(spec) {
|
|
|
5678
5961
|
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
5679
5962
|
}
|
|
5680
5963
|
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
5681
|
-
for (const size of ["phone", "tablet", "desktop"]) {
|
|
5964
|
+
for (const size of ["phone", "tablet", "desktop", "wide"]) {
|
|
5682
5965
|
const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
|
|
5683
5966
|
if (!line) {
|
|
5684
5967
|
problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
|
|
@@ -5719,7 +6002,7 @@ function navProblems(spec) {
|
|
|
5719
6002
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
5720
6003
|
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
5721
6004
|
const problems = [];
|
|
5722
|
-
for (const size of ["tablet", "desktop"]) {
|
|
6005
|
+
for (const size of ["tablet", "landscape", "desktop", "wide"]) {
|
|
5723
6006
|
const block = sizeBlock(sizes, size);
|
|
5724
6007
|
const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
|
|
5725
6008
|
if (!nav || NONE_RE.test(nav)) continue;
|
|
@@ -5733,10 +6016,10 @@ function navProblems(spec) {
|
|
|
5733
6016
|
// src/commands/gate.ts
|
|
5734
6017
|
var MAX_BLOCKS = 3;
|
|
5735
6018
|
function lastJigCommand(transcriptPath) {
|
|
5736
|
-
if (!transcriptPath || !
|
|
6019
|
+
if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
|
|
5737
6020
|
let text;
|
|
5738
6021
|
try {
|
|
5739
|
-
text =
|
|
6022
|
+
text = readFileSync28(transcriptPath, "utf8");
|
|
5740
6023
|
} catch {
|
|
5741
6024
|
return void 0;
|
|
5742
6025
|
}
|
|
@@ -5754,10 +6037,10 @@ function commandProblems(root, command) {
|
|
|
5754
6037
|
const problems = [];
|
|
5755
6038
|
const spec = newestSpec(root);
|
|
5756
6039
|
if (command === "decide") {
|
|
5757
|
-
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));
|
|
5758
6041
|
if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
5759
6042
|
else {
|
|
5760
|
-
const body =
|
|
6043
|
+
const body = readFileSync28(file, "utf8");
|
|
5761
6044
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
5762
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.`");
|
|
5763
6046
|
}
|
|
@@ -5778,15 +6061,18 @@ function commandProblems(root, command) {
|
|
|
5778
6061
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
5779
6062
|
if (/^approved/i.test(mockup)) {
|
|
5780
6063
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
5781
|
-
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.`);
|
|
5782
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.`);
|
|
5783
6066
|
}
|
|
5784
6067
|
}
|
|
5785
6068
|
if (command === "critique") {
|
|
5786
|
-
const dir =
|
|
5787
|
-
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"))) : [];
|
|
5788
6071
|
for (const surface of surfaces) {
|
|
5789
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
|
+
}
|
|
5790
6076
|
if (v.ok && v.screen.state === "skipped") {
|
|
5791
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.`);
|
|
5792
6078
|
}
|
|
@@ -5803,13 +6089,13 @@ function surfacePage(projectRoot, surface) {
|
|
|
5803
6089
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
5804
6090
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
5805
6091
|
for (const candidate of candidates) {
|
|
5806
|
-
if (candidate &&
|
|
6092
|
+
if (candidate && existsSync22(join35(projectRoot, candidate))) return candidate;
|
|
5807
6093
|
}
|
|
5808
6094
|
return void 0;
|
|
5809
6095
|
}
|
|
5810
6096
|
function gate(opts) {
|
|
5811
6097
|
const root = opts.projectRoot;
|
|
5812
|
-
if (!
|
|
6098
|
+
if (!existsSync22(join35(root, "jig.config.json")) && !existsSync22(join35(root, ".jig"))) {
|
|
5813
6099
|
return { block: false, reason: "" };
|
|
5814
6100
|
}
|
|
5815
6101
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -5828,11 +6114,11 @@ function gate(opts) {
|
|
|
5828
6114
|
);
|
|
5829
6115
|
}
|
|
5830
6116
|
}
|
|
5831
|
-
const critiqueDir =
|
|
5832
|
-
if (
|
|
6117
|
+
const critiqueDir = join35(root, ".jig", "critique");
|
|
6118
|
+
if (existsSync22(critiqueDir)) {
|
|
5833
6119
|
for (const surface of readdirSync15(critiqueDir)) {
|
|
5834
|
-
const dir =
|
|
5835
|
-
if (!
|
|
6120
|
+
const dir = join35(critiqueDir, surface);
|
|
6121
|
+
if (!existsSync22(join35(dir, "screen.json")) && !existsSync22(join35(dir, "code.json"))) continue;
|
|
5836
6122
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5837
6123
|
if (!v.ok) {
|
|
5838
6124
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -5846,10 +6132,10 @@ function gate(opts) {
|
|
|
5846
6132
|
}
|
|
5847
6133
|
const session = opts.input.session_id ?? "unknown";
|
|
5848
6134
|
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
5849
|
-
const stateFile =
|
|
6135
|
+
const stateFile = join35(root, ".jig", "gate.json");
|
|
5850
6136
|
let state = {};
|
|
5851
6137
|
try {
|
|
5852
|
-
state = JSON.parse(
|
|
6138
|
+
state = JSON.parse(readFileSync28(stateFile, "utf8"));
|
|
5853
6139
|
} catch {
|
|
5854
6140
|
}
|
|
5855
6141
|
if (problems.length === 0) {
|
|
@@ -5879,15 +6165,112 @@ ${problems.join("\n\n")}`
|
|
|
5879
6165
|
}
|
|
5880
6166
|
function save(file, state) {
|
|
5881
6167
|
try {
|
|
5882
|
-
mkdirSync8(
|
|
6168
|
+
mkdirSync8(join35(file, ".."), { recursive: true });
|
|
5883
6169
|
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
5884
6170
|
} catch {
|
|
5885
6171
|
}
|
|
5886
6172
|
}
|
|
5887
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
|
+
var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
|
|
6181
|
+
var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
|
|
6182
|
+
var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
|
|
6183
|
+
var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
|
|
6184
|
+
var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
|
|
6185
|
+
function isPage(file, raw) {
|
|
6186
|
+
if (TEST_FILE.test(file)) return false;
|
|
6187
|
+
if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
|
|
6188
|
+
const route = ROUTE_DIR.exec(file);
|
|
6189
|
+
if (route) {
|
|
6190
|
+
const rest = route[1];
|
|
6191
|
+
const name = rest.split("/").pop();
|
|
6192
|
+
if (name.startsWith("+")) return name === "+page.svelte";
|
|
6193
|
+
if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
|
|
6194
|
+
if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
|
|
6195
|
+
if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
|
|
6196
|
+
return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
|
|
6197
|
+
}
|
|
6198
|
+
return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
|
|
6199
|
+
}
|
|
6200
|
+
function routeOf(file) {
|
|
6201
|
+
const path = file.replace(/^(?:src\/pages|app\/pages|app\/routes|src\/routes|pages|routes|app|src|public|content)\//, "").replace(/(^|\/)(index|\+?page)\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "$1").replace(/\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "").replace(/\/$/, "");
|
|
6202
|
+
return `/${path}`.replace(/\/+/g, "/");
|
|
6203
|
+
}
|
|
6204
|
+
function seo(opts) {
|
|
6205
|
+
const root = opts.projectRoot;
|
|
6206
|
+
const files = selectFiles(root, true).files;
|
|
6207
|
+
const findings = [];
|
|
6208
|
+
const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
|
|
6209
|
+
const pages = [];
|
|
6210
|
+
let sitemapFiles = [];
|
|
6211
|
+
let robotsFiles = [];
|
|
6212
|
+
const sitemapRoutes = /* @__PURE__ */ new Map();
|
|
6213
|
+
const pathLocs = [];
|
|
6214
|
+
for (const file of files) {
|
|
6215
|
+
let raw;
|
|
6216
|
+
try {
|
|
6217
|
+
raw = readFileSync29(join36(root, file), "utf8");
|
|
6218
|
+
} catch {
|
|
6219
|
+
continue;
|
|
6220
|
+
}
|
|
6221
|
+
if (SITEMAP_FILE.test(file)) {
|
|
6222
|
+
sitemapFiles.push(file);
|
|
6223
|
+
for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
|
|
6224
|
+
const value = (match[1] ?? match[2] ?? "").trim();
|
|
6225
|
+
if (!value) continue;
|
|
6226
|
+
if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
|
|
6227
|
+
const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
|
|
6228
|
+
sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
|
|
6229
|
+
}
|
|
6230
|
+
continue;
|
|
6231
|
+
}
|
|
6232
|
+
if (ROBOTS_FILE.test(file)) {
|
|
6233
|
+
robotsFiles.push(file);
|
|
6234
|
+
continue;
|
|
6235
|
+
}
|
|
6236
|
+
if (!isPage(file, raw)) continue;
|
|
6237
|
+
const found = readMetadata(raw);
|
|
6238
|
+
const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
|
|
6239
|
+
pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
|
|
6240
|
+
}
|
|
6241
|
+
for (const page of pages.filter((p) => p.noindex)) {
|
|
6242
|
+
const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
|
|
6243
|
+
if (listed) {
|
|
6244
|
+
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.`);
|
|
6245
|
+
}
|
|
6246
|
+
}
|
|
6247
|
+
if (pathLocs.length > 0) {
|
|
6248
|
+
const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
|
|
6249
|
+
add(pathLocs[0].file, "J-124", "error", `${pathLocs.length} sitemap entr${pathLocs.length === 1 ? "y is a path" : "ies are paths"} (${shown}), not a full URL. A crawler drops them; with no origin yet, no sitemap is the honest one.`);
|
|
6250
|
+
}
|
|
6251
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
6252
|
+
for (const page of pages) {
|
|
6253
|
+
if (!page.title || page.noindex) continue;
|
|
6254
|
+
byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
|
|
6255
|
+
}
|
|
6256
|
+
for (const [title, where] of byTitle) {
|
|
6257
|
+
if (where.length > 1) {
|
|
6258
|
+
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.`);
|
|
6259
|
+
}
|
|
6260
|
+
}
|
|
6261
|
+
const indexable = pages.filter((p) => !p.noindex);
|
|
6262
|
+
const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
|
|
6263
|
+
if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
|
|
6264
|
+
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.");
|
|
6265
|
+
}
|
|
6266
|
+
const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
|
|
6267
|
+
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}`;
|
|
6268
|
+
return { findings, pages: pages.length, indexable: indexable.length, line };
|
|
6269
|
+
}
|
|
6270
|
+
|
|
5888
6271
|
// src/index.ts
|
|
5889
6272
|
var packageRoot = getPackageRoot();
|
|
5890
|
-
var { version } = JSON.parse(
|
|
6273
|
+
var { version } = JSON.parse(readFileSync30(join37(packageRoot, "package.json"), "utf8"));
|
|
5891
6274
|
function warnIfUnpublishedPin() {
|
|
5892
6275
|
if (isPublishedBuild(packageRoot)) return;
|
|
5893
6276
|
console.warn(
|
|
@@ -6013,6 +6396,24 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
6013
6396
|
process.exit(1);
|
|
6014
6397
|
}
|
|
6015
6398
|
});
|
|
6399
|
+
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) => {
|
|
6400
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
6401
|
+
try {
|
|
6402
|
+
const result = seo({ projectRoot, mode: resolveSurfaceMode(projectRoot) });
|
|
6403
|
+
if (opts.json) {
|
|
6404
|
+
console.log(JSON.stringify(result.findings, null, 2));
|
|
6405
|
+
} else {
|
|
6406
|
+
const symbol = { error: "\u2717", warning: "\u26A0", note: "\xB7" };
|
|
6407
|
+
for (const f of result.findings) console.log(` ${symbol[f.severity]} ${f.ruleId} ${f.message} ${f.file}`);
|
|
6408
|
+
if (result.findings.length === 0) console.log(" Nothing to fix in what a search engine reads.");
|
|
6409
|
+
console.log(` ${result.line}`);
|
|
6410
|
+
}
|
|
6411
|
+
process.exit(result.findings.some((f) => f.severity === "error") ? 1 : 0);
|
|
6412
|
+
} catch (err) {
|
|
6413
|
+
console.error(err.message);
|
|
6414
|
+
process.exit(1);
|
|
6415
|
+
}
|
|
6416
|
+
});
|
|
6016
6417
|
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) => {
|
|
6017
6418
|
if (opts.run) {
|
|
6018
6419
|
if (!opts.save) {
|
|
@@ -6055,7 +6456,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
6055
6456
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
6056
6457
|
let input = {};
|
|
6057
6458
|
try {
|
|
6058
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
6459
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync30(0, "utf8") || "{}");
|
|
6059
6460
|
} catch {
|
|
6060
6461
|
}
|
|
6061
6462
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -6092,4 +6493,13 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
6092
6493
|
process.exit(1);
|
|
6093
6494
|
}
|
|
6094
6495
|
});
|
|
6496
|
+
function resolveSurfaceMode(projectRoot) {
|
|
6497
|
+
try {
|
|
6498
|
+
const config = JSON.parse(readFileSync30(join37(projectRoot, "jig.config.json"), "utf8"));
|
|
6499
|
+
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
6500
|
+
return modes.length === 1 ? modes[0] : void 0;
|
|
6501
|
+
} catch {
|
|
6502
|
+
return void 0;
|
|
6503
|
+
}
|
|
6504
|
+
}
|
|
6095
6505
|
program.parse();
|