jig-ui 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/README.md +158 -7
- package/dist/index.js +659 -184
- package/examples/G-161.html +7 -0
- package/examples/G-162.html +18 -0
- package/examples/G-163.html +7 -0
- package/examples/I-148.html +7 -0
- package/examples/I-149.html +7 -0
- package/examples/I-150.html +7 -0
- package/examples/I-151.html +7 -0
- package/examples/I-152.html +7 -0
- package/examples/I-153.html +7 -0
- package/examples/I-154.html +7 -0
- package/examples/I-155.html +7 -0
- package/examples/I-156.html +7 -0
- package/examples/I-157.html +7 -0
- package/examples/I-158.html +7 -0
- package/examples/I-159.html +7 -0
- package/examples/I-160.html +7 -0
- package/package.json +2 -2
- package/rules/00-anti-patterns.md +19 -0
- package/rules/01-modes.md +1 -1
- package/rules/02-tokens.md +3 -0
- package/rules/03-patterns.md +2 -1
- package/rules/05-copy.md +88 -2
- package/rules.index.json +114 -0
- package/templates/COMMAND.md.tmpl +286 -22
- package/templates/SKILL.md.tmpl +6 -1
- 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 readFileSync36 } from "fs";
|
|
6
6
|
import { homedir as homedir2 } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join43 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -28,7 +28,7 @@ function assetRoot(startDir = getPackageRoot()) {
|
|
|
28
28
|
let current = startDir;
|
|
29
29
|
const { root } = parse(startDir);
|
|
30
30
|
while (true) {
|
|
31
|
-
if (existsSync(join(current, ASSET_MARKER))) return current;
|
|
31
|
+
if (existsSync(join(current, ASSET_MARKER))) return sourceOf(current);
|
|
32
32
|
if (current === root) {
|
|
33
33
|
throw new Error(
|
|
34
34
|
`assetRoot(): could not find an ancestor of "${startDir}" containing "${ASSET_MARKER}"`
|
|
@@ -37,6 +37,11 @@ function assetRoot(startDir = getPackageRoot()) {
|
|
|
37
37
|
current = dirname(current);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
function sourceOf(found) {
|
|
41
|
+
if (isPublishedBuild(found)) return found;
|
|
42
|
+
const repo = join(found, "..", "..");
|
|
43
|
+
return existsSync(join(repo, ASSET_MARKER)) && existsSync(join(repo, "packages", "cli", "package.json")) ? repo : found;
|
|
44
|
+
}
|
|
40
45
|
function isPublishedBuild(packageRoot2) {
|
|
41
46
|
return packageRoot2.split(/[\\/]/).includes("node_modules");
|
|
42
47
|
}
|
|
@@ -1522,8 +1527,9 @@ Example: ${example} (a dont and a do, each a self-contained HTML fragment)` : re
|
|
|
1522
1527
|
const idBody = ids.filter((e) => !idTitle.includes(e) && matches(e.text, needle));
|
|
1523
1528
|
const secTitle = secs.filter((e) => matches(e.title, needle));
|
|
1524
1529
|
const secBody = secs.filter((e) => !secTitle.includes(e) && matches(e.text, needle));
|
|
1530
|
+
const opens = (e) => e.title.toLowerCase().startsWith(needle.toLowerCase()) ? 0 : 1;
|
|
1525
1531
|
const hits = [
|
|
1526
|
-
...idTitle.sort((a, b) => byNumber(a.id, b.id)),
|
|
1532
|
+
...idTitle.sort((a, b) => opens(a) - opens(b) || byNumber(a.id, b.id)),
|
|
1527
1533
|
...idBody.sort((a, b) => byNumber(a.id, b.id)),
|
|
1528
1534
|
...secTitle,
|
|
1529
1535
|
...secBody
|
|
@@ -3505,19 +3511,21 @@ function indentedProse(source) {
|
|
|
3505
3511
|
const blank3 = (m) => " ".repeat(m.length);
|
|
3506
3512
|
return source.split("\n").map((line) => /^\s*(-|\/\/|#(?!\w)|=)/.test(line) ? blank3(line) : line).join("\n");
|
|
3507
3513
|
}
|
|
3514
|
+
function readerSpans(file, raw) {
|
|
3515
|
+
const markdown = MARKDOWN.test(file);
|
|
3516
|
+
const indented = INDENTED.test(file);
|
|
3517
|
+
const masked = markdown ? markdownProse(raw) : indented ? indentedProse(maskNonProse(raw)) : maskNonProse(raw);
|
|
3518
|
+
const script = SCRIPT.test(file);
|
|
3519
|
+
return markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
|
|
3520
|
+
}
|
|
3508
3521
|
var emDash = {
|
|
3509
3522
|
name: "em-dash",
|
|
3510
3523
|
appliesTo: (file) => isReaderText(file),
|
|
3511
3524
|
run(_source, file, ctx) {
|
|
3512
|
-
const markdown = MARKDOWN.test(file);
|
|
3513
|
-
const indented = INDENTED.test(file);
|
|
3514
|
-
const masked = markdown ? markdownProse(ctx.raw) : indented ? indentedProse(maskNonProse(ctx.raw)) : maskNonProse(ctx.raw);
|
|
3515
3525
|
const starts = buildLineIndex(ctx.raw);
|
|
3516
3526
|
const findings = [];
|
|
3517
3527
|
const seen = /* @__PURE__ */ new Set();
|
|
3518
|
-
const
|
|
3519
|
-
const spans = markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
|
|
3520
|
-
for (const span of spans) {
|
|
3528
|
+
for (const span of readerSpans(file, ctx.raw)) {
|
|
3521
3529
|
if (!EM_DASH.test(span.text)) continue;
|
|
3522
3530
|
for (const hit of span.text.matchAll(/\u2014/g)) {
|
|
3523
3531
|
const line = lineForOffset(starts, span.index + hit.index);
|
|
@@ -3539,6 +3547,135 @@ var emDash = {
|
|
|
3539
3547
|
}
|
|
3540
3548
|
};
|
|
3541
3549
|
|
|
3550
|
+
// src/check/detectors/prose.ts
|
|
3551
|
+
var FORMULA = new RegExp(
|
|
3552
|
+
[
|
|
3553
|
+
String.raw`in today['’]s (?:fast[- ]paced|rapidly|ever[- ]|increasingly|digital|modern|busy)`,
|
|
3554
|
+
String.raw`in an (?:increasingly|ever[- ](?:changing|evolving)) \w+ (?:world|landscape|age|marketplace|era)`,
|
|
3555
|
+
String.raw`as technology continues to evolve`,
|
|
3556
|
+
String.raw`it(?: is|['’]s) (?:important|worth) (?:to note|noting)`,
|
|
3557
|
+
String.raw`let['’]s (?:dive|delve)\b`,
|
|
3558
|
+
String.raw`here['’]s the thing\b`,
|
|
3559
|
+
String.raw`without further ado`,
|
|
3560
|
+
String.raw`in conclusion\b`,
|
|
3561
|
+
String.raw`only time will tell`,
|
|
3562
|
+
String.raw`the future (?:looks|is) (?:bright|incredibly promising)`
|
|
3563
|
+
].map((p) => `\\b${p}`).join("|"),
|
|
3564
|
+
"gi"
|
|
3565
|
+
);
|
|
3566
|
+
function maskQuotations(raw) {
|
|
3567
|
+
const blank3 = (m) => m.replace(/[^\n]/g, " ");
|
|
3568
|
+
return raw.replace(/<blockquote\b[\s\S]*?<\/blockquote\s*>/gi, blank3).replace(/<q\b[\s\S]*?<\/q\s*>/gi, blank3).replace(/^ {0,3}>[^\n]*/gm, blank3);
|
|
3569
|
+
}
|
|
3570
|
+
var formulaPhrase = {
|
|
3571
|
+
name: "formula-phrase",
|
|
3572
|
+
appliesTo: (file) => isReaderText(file),
|
|
3573
|
+
run(_source, file, ctx) {
|
|
3574
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3575
|
+
const findings = [];
|
|
3576
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3577
|
+
for (const span of readerSpans(file, maskQuotations(ctx.raw))) {
|
|
3578
|
+
for (const hit of span.text.matchAll(FORMULA)) {
|
|
3579
|
+
const line = lineForOffset(starts, span.index + hit.index);
|
|
3580
|
+
if (seen.has(line)) continue;
|
|
3581
|
+
seen.add(line);
|
|
3582
|
+
findings.push(
|
|
3583
|
+
mkFinding(
|
|
3584
|
+
ctx,
|
|
3585
|
+
"formula-phrase",
|
|
3586
|
+
file,
|
|
3587
|
+
line,
|
|
3588
|
+
`a formula phrase ("${hit[0]}"): start with the point, and end when it has been made`,
|
|
3589
|
+
sourceLine(ctx.raw, line)
|
|
3590
|
+
)
|
|
3591
|
+
);
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
3595
|
+
}
|
|
3596
|
+
};
|
|
3597
|
+
var HEADING3 = /^ {0,3}#{1,6}[ \t]+(.+)$/gm;
|
|
3598
|
+
var FENCE = /^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[^\n]*$/gm;
|
|
3599
|
+
var headingEmoji = {
|
|
3600
|
+
name: "heading-emoji",
|
|
3601
|
+
appliesTo: (file) => isReaderText(file) && hasExtension(file, [".md", ".markdown"]) && !isStyleBearing(file),
|
|
3602
|
+
run(_source, file, ctx) {
|
|
3603
|
+
const masked = ctx.raw.replace(FENCE, (m) => m.replace(/[^\n]/g, " "));
|
|
3604
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3605
|
+
const findings = [];
|
|
3606
|
+
for (const heading of masked.matchAll(HEADING3)) {
|
|
3607
|
+
const glyph = [...heading[1].matchAll(EMOJI_RE)].find((m) => !TEXTUAL.has(m[0]));
|
|
3608
|
+
if (!glyph) continue;
|
|
3609
|
+
const line = lineForOffset(starts, heading.index);
|
|
3610
|
+
findings.push(
|
|
3611
|
+
mkFinding(
|
|
3612
|
+
ctx,
|
|
3613
|
+
"heading-emoji",
|
|
3614
|
+
file,
|
|
3615
|
+
line,
|
|
3616
|
+
`an emoji in a heading ("${glyph[0]}"): let the words carry it; a screen reader reads the emoji's name first`,
|
|
3617
|
+
sourceLine(ctx.raw, line)
|
|
3618
|
+
)
|
|
3619
|
+
);
|
|
3620
|
+
}
|
|
3621
|
+
return findings;
|
|
3622
|
+
}
|
|
3623
|
+
};
|
|
3624
|
+
|
|
3625
|
+
// src/check/detectors/layout-motion.ts
|
|
3626
|
+
var LAYOUT = String.raw`(?:(?:min-|max-)?(?:width|height)|(?:inline|block)-size|top|left|right|bottom|inset(?:-[a-z-]+)?|margin(?:-[a-z-]+)?|padding(?:-[a-z-]+)?)`;
|
|
3627
|
+
var LAYOUT_NAME = new RegExp(`^${LAYOUT}$`, "i");
|
|
3628
|
+
var TRANSITION = /(?<![-\w])transition(-property)?\s*:\s*([^;}]+)/gi;
|
|
3629
|
+
var KEYFRAMES = /@(?:-webkit-)?keyframes\b[^{]*\{/gi;
|
|
3630
|
+
var KEYFRAME_DECL = new RegExp(`(?<![-\\w])(${LAYOUT})\\s*:`, "gi");
|
|
3631
|
+
var TAILWIND = new RegExp(`(?<![-\\w\\[])transition-(all|\\[[^\\]\\s]*?\\b${LAYOUT}\\b[^\\]\\s]*\\])(?![-\\w])`, "gi");
|
|
3632
|
+
function namedProperties(value, longhand) {
|
|
3633
|
+
return value.split(",").map((item) => {
|
|
3634
|
+
const words = item.trim().split(/\s+/);
|
|
3635
|
+
return longhand ? words[0] : words.find((w) => /^[a-z-]+$/i.test(w) && !/^(ease|ease-in|ease-out|ease-in-out|linear|step-start|step-end|allow-discrete|normal|none)$/i.test(w)) ?? "";
|
|
3636
|
+
}).filter(Boolean);
|
|
3637
|
+
}
|
|
3638
|
+
function blockEnd(source, open) {
|
|
3639
|
+
let depth = 0;
|
|
3640
|
+
for (let i = open; i < source.length; i++) {
|
|
3641
|
+
if (source[i] === "{") depth++;
|
|
3642
|
+
else if (source[i] === "}" && --depth === 0) return i;
|
|
3643
|
+
}
|
|
3644
|
+
return source.length;
|
|
3645
|
+
}
|
|
3646
|
+
var layoutMotion = {
|
|
3647
|
+
name: "layout-motion",
|
|
3648
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3649
|
+
run(source, file, ctx) {
|
|
3650
|
+
const findings = [];
|
|
3651
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3652
|
+
const report2 = (text, offset, message) => {
|
|
3653
|
+
const line = lineForOffset(buildLineIndex(text), offset);
|
|
3654
|
+
if (seen.has(line)) return;
|
|
3655
|
+
seen.add(line);
|
|
3656
|
+
findings.push(mkFinding(ctx, "layout-motion", file, line, message, sourceLine(text, line)));
|
|
3657
|
+
};
|
|
3658
|
+
for (const m of source.matchAll(TRANSITION)) {
|
|
3659
|
+
const names = namedProperties(m[2], Boolean(m[1]));
|
|
3660
|
+
const bad = names.find((n) => n.toLowerCase() === "all" || LAYOUT_NAME.test(n));
|
|
3661
|
+
if (!bad) continue;
|
|
3662
|
+
report2(source, m.index, bad.toLowerCase() === "all" ? "a transition on `all`: name the properties it covers, and animate transform and opacity" : `a transition on \`${bad}\`, which lays the page out again every frame: animate transform or opacity instead`);
|
|
3663
|
+
}
|
|
3664
|
+
for (const k of source.matchAll(KEYFRAMES)) {
|
|
3665
|
+
const open = k.index + k[0].length - 1;
|
|
3666
|
+
const body = source.slice(open, blockEnd(source, open));
|
|
3667
|
+
for (const d of body.matchAll(KEYFRAME_DECL)) {
|
|
3668
|
+
report2(source, open + d.index, `a keyframe animates \`${d[1]}\`, which lays the page out again every frame: animate transform or opacity instead`);
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
const raw = maskProseComments(ctx.raw);
|
|
3672
|
+
for (const t of raw.matchAll(TAILWIND)) {
|
|
3673
|
+
report2(ctx.raw, t.index, `\`${t[0]}\` animates ${t[1] === "all" ? "every property that changes" : "layout"}: use \`transition\`, \`transition-opacity\` or \`transition-transform\``);
|
|
3674
|
+
}
|
|
3675
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
3676
|
+
}
|
|
3677
|
+
};
|
|
3678
|
+
|
|
3542
3679
|
// src/check/detectors/semantic-element.ts
|
|
3543
3680
|
var BODY = /<body\b/i;
|
|
3544
3681
|
var MAIN = /<main\b|role\s*=\s*["']main["']/i;
|
|
@@ -4094,6 +4231,9 @@ var DETECTORS = [
|
|
|
4094
4231
|
menuState,
|
|
4095
4232
|
undeclaredToken,
|
|
4096
4233
|
emDash,
|
|
4234
|
+
formulaPhrase,
|
|
4235
|
+
headingEmoji,
|
|
4236
|
+
layoutMotion,
|
|
4097
4237
|
semanticElement,
|
|
4098
4238
|
metadata,
|
|
4099
4239
|
interfaceSafety,
|
|
@@ -5349,6 +5489,7 @@ async function init(opts) {
|
|
|
5349
5489
|
log(`Proposed brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%) \u2014 ${proposal.detail}`);
|
|
5350
5490
|
for (const line of describeValidation(validation)) log(line);
|
|
5351
5491
|
let surfaces = DEFAULT_SURFACES;
|
|
5492
|
+
let critique;
|
|
5352
5493
|
if (!opts.yes) {
|
|
5353
5494
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
5354
5495
|
const answer = await prompt(
|
|
@@ -5376,6 +5517,12 @@ async function init(opts) {
|
|
|
5376
5517
|
` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES2.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
|
|
5377
5518
|
);
|
|
5378
5519
|
}
|
|
5520
|
+
const critiqueAnswer = (await prompt(
|
|
5521
|
+
`When should pages be critiqued? Enter to decide page by page, 'each' after every build, or 'ship' to leave them for jig ship: `
|
|
5522
|
+
)).toLowerCase();
|
|
5523
|
+
if (/^(e|each)$/.test(critiqueAnswer)) critique = "each";
|
|
5524
|
+
else if (/^(s|ship|at-ship)$/.test(critiqueAnswer)) critique = "at-ship";
|
|
5525
|
+
else if (critiqueAnswer) log(` Could not read '${critiqueAnswer}' \u2014 leaving it page by page; each spec will ask.`);
|
|
5379
5526
|
}
|
|
5380
5527
|
if (opts.yes) {
|
|
5381
5528
|
const declared = readConfiguredSurfaces(join28(opts.projectRoot, "jig.config.json"));
|
|
@@ -5483,7 +5630,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5483
5630
|
}
|
|
5484
5631
|
}
|
|
5485
5632
|
if (configAction === "written") {
|
|
5486
|
-
const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
|
|
5633
|
+
const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces, ...critique ? { critique } : {} };
|
|
5487
5634
|
const content = `${JSON.stringify(config, null, 2)}
|
|
5488
5635
|
`;
|
|
5489
5636
|
writeFileSync4(configAbsPath, content, "utf8");
|
|
@@ -5499,6 +5646,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5499
5646
|
const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
5500
5647
|
const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
|
|
5501
5648
|
const merged = {
|
|
5649
|
+
...existing,
|
|
5650
|
+
...existing.critique === void 0 && critique ? { critique } : {},
|
|
5502
5651
|
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
5503
5652
|
// Same contract as the 'written' branch: never invent a mapping under
|
|
5504
5653
|
// `--yes`. An existing declaration is always kept.
|
|
@@ -6516,7 +6665,7 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6516
6665
|
// src/check/decisions.ts
|
|
6517
6666
|
import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
|
|
6518
6667
|
import { join as join35, posix as posix3 } from "path";
|
|
6519
|
-
var
|
|
6668
|
+
var HEADING4 = /^(#{2,3})\s+(.+?)\s*$/gm;
|
|
6520
6669
|
var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
|
|
6521
6670
|
function decisionsFile(projectRoot) {
|
|
6522
6671
|
const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
|
|
@@ -6543,7 +6692,7 @@ function decisionHeadings(projectRoot) {
|
|
|
6543
6692
|
} catch {
|
|
6544
6693
|
return headings;
|
|
6545
6694
|
}
|
|
6546
|
-
for (const match of body.matchAll(
|
|
6695
|
+
for (const match of body.matchAll(HEADING4)) {
|
|
6547
6696
|
const name = match[2].replace(/[`*]/g, "").trim();
|
|
6548
6697
|
if (!name || NOT_A_DECISION.test(name)) continue;
|
|
6549
6698
|
if (!headings.has(name)) headings.set(name, match[0].trim());
|
|
@@ -6598,14 +6747,12 @@ function unsourcedReasons(current, before, opts = {}) {
|
|
|
6598
6747
|
return problems;
|
|
6599
6748
|
}
|
|
6600
6749
|
function quotesNotFrom(current, before, words) {
|
|
6601
|
-
const norm = (t) => t.toLowerCase().replace(/[‘’]/g, "'").replace(/\s+/g, " ").trim();
|
|
6602
|
-
const said = norm(words);
|
|
6603
6750
|
const problems = [];
|
|
6604
6751
|
for (const [name, whys] of changedWhys(current, before, true)) {
|
|
6605
6752
|
for (const why of whys) {
|
|
6606
6753
|
const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
|
|
6607
6754
|
if (/inferred/i.test(label)) continue;
|
|
6608
|
-
const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => q
|
|
6755
|
+
const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
|
|
6609
6756
|
if (missing.length) {
|
|
6610
6757
|
problems.push(`"${name}": its \`**${label}:**\` quotes "${missing[0]}", which the owner's words in tweak.json (\`change\`) do not say. Quote the owner as tweak.json records them, or put your reading under \`**Why (inferred):**\`.`);
|
|
6611
6758
|
break;
|
|
@@ -6614,6 +6761,11 @@ function quotesNotFrom(current, before, words) {
|
|
|
6614
6761
|
}
|
|
6615
6762
|
return problems;
|
|
6616
6763
|
}
|
|
6764
|
+
var normQuote = (t) => t.toLowerCase().replace(/\\(?=["'\\])/g, "").replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, " ").trim();
|
|
6765
|
+
function quoteHeld(quote, words) {
|
|
6766
|
+
const said = normQuote(words);
|
|
6767
|
+
return quote.split(/…|\.\.\./).map((part) => normQuote(part).replace(/^[\s.,;:]+|[\s.,;:!?]+$/g, "")).filter(Boolean).every((part) => said.includes(part));
|
|
6768
|
+
}
|
|
6617
6769
|
|
|
6618
6770
|
// src/check/spec-shape.ts
|
|
6619
6771
|
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
|
|
@@ -6644,6 +6796,22 @@ function specProblems(spec) {
|
|
|
6644
6796
|
if (specIndexableField(front) === "unreadable") {
|
|
6645
6797
|
problems.push(`${spec.path}: \`indexable:\` is neither true nor false. Write \`indexable: true\` or \`indexable: false\`; a per-page override and its reason go in the body, where they can be read without being parsed.`);
|
|
6646
6798
|
}
|
|
6799
|
+
const critique = /^\s*critique\s*:\s*(.*)$/im.exec(front)?.[1]?.replace(/\s+#.*$/, "").trim();
|
|
6800
|
+
if (critique !== void 0 && !/^(each|at-ship)$/i.test(critique)) {
|
|
6801
|
+
problems.push(`${spec.path}: \`critique: ${critique}\` is neither \`each\` nor \`at-ship\`. Leave it out to follow the project's default.`);
|
|
6802
|
+
}
|
|
6803
|
+
const motion = /^motion\s*:[ \t]*(.*)\n?((?:[ \t]+-.*\n?)*)/im.exec(front);
|
|
6804
|
+
if (motion) {
|
|
6805
|
+
const inline = motion[1].replace(/\s+#.*$/, "").trim();
|
|
6806
|
+
const items = motion[2].split("\n").map((l) => l.replace(/^[ \t]+-\s*/, "").trim()).filter(Boolean);
|
|
6807
|
+
if (!items.length && !/^none$/i.test(inline)) {
|
|
6808
|
+
problems.push(`${spec.path}: \`motion:\` is neither \`none\` nor a list. List each movement as \`- <what moves>: <how>, on <trigger>; tells <what the reader learns>\`, or write \`motion: none\`.`);
|
|
6809
|
+
}
|
|
6810
|
+
const unsaid = items.filter((i) => !/\bon\b/i.test(i) || !/\btells?\b/i.test(i));
|
|
6811
|
+
if (unsaid.length) {
|
|
6812
|
+
problems.push(`${spec.path}: ${unsaid.length === 1 ? "a movement in `motion:` does" : `${unsaid.length} movements in \`motion:\` do`} not say what triggers it and what it tells the reader ("${unsaid[0]}"). Write \`on <trigger>; tells <what>\`, or cut the movement.`);
|
|
6813
|
+
}
|
|
6814
|
+
}
|
|
6647
6815
|
for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
|
|
6648
6816
|
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
6649
6817
|
}
|
|
@@ -6787,7 +6955,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6787
6955
|
seen.add(id);
|
|
6788
6956
|
const pass = otherPass.get(id);
|
|
6789
6957
|
if (!required.includes(id) && !extraAllowed.has(id)) {
|
|
6790
|
-
errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict.`);
|
|
6958
|
+
errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict. A difference from the spec, or from a source the page quotes, is not a rule: list it under \`differences\` in the same file.`);
|
|
6791
6959
|
continue;
|
|
6792
6960
|
}
|
|
6793
6961
|
if (typeof v.verdict !== "string" || !RULE_VERDICTS.includes(v.verdict)) {
|
|
@@ -6808,6 +6976,16 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6808
6976
|
if (v.verdict === "finding") findings++;
|
|
6809
6977
|
}
|
|
6810
6978
|
reasonProblems(`${name}.json`, reasons, errors);
|
|
6979
|
+
const differences = file.differences;
|
|
6980
|
+
if (differences !== void 0) {
|
|
6981
|
+
if (!Array.isArray(differences)) errors.push(`${name}.json: \`differences\` is not a list.`);
|
|
6982
|
+
else for (const d of differences) {
|
|
6983
|
+
const said = (x) => typeof x === "string" && x.trim() !== "";
|
|
6984
|
+
if (!said(d?.what) || !said(d?.where) || !said(d?.against)) {
|
|
6985
|
+
errors.push(`${name}.json: a difference needs \`what\` (what differs), \`where\` (the page's file and line) and \`against\` (the spec line or source it differs from).`);
|
|
6986
|
+
} else findings++;
|
|
6987
|
+
}
|
|
6988
|
+
}
|
|
6811
6989
|
const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
|
|
6812
6990
|
if (missing.length) {
|
|
6813
6991
|
errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
|
|
@@ -6847,7 +7025,7 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6847
7025
|
}
|
|
6848
7026
|
seen.add(match);
|
|
6849
7027
|
if (typeof v.verdict !== "string" || !VERDICTS.includes(v.verdict)) {
|
|
6850
|
-
errors.push(`decisions.json: "${match}" has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
|
|
7028
|
+
errors.push(v.verdict === "ruled" ? `decisions.json: "${match}" is marked ruled. A decision is not excused by a decision: judge whether the page follows it, ok, finding or n/a. \`ruled\` is for a rule in screen.json or code.json that a decision overrides.` : `decisions.json: "${match}" has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
|
|
6851
7029
|
continue;
|
|
6852
7030
|
}
|
|
6853
7031
|
const reason = typeof v.reason === "string" ? v.reason.trim() : "";
|
|
@@ -7053,9 +7231,9 @@ function sourceChanges(git, from, to) {
|
|
|
7053
7231
|
}
|
|
7054
7232
|
|
|
7055
7233
|
// src/commands/gate.ts
|
|
7056
|
-
import { existsSync as
|
|
7057
|
-
import { createHash as
|
|
7058
|
-
import { join as
|
|
7234
|
+
import { existsSync as existsSync26, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync35, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
|
|
7235
|
+
import { createHash as createHash3 } from "crypto";
|
|
7236
|
+
import { join as join42 } from "path";
|
|
7059
7237
|
|
|
7060
7238
|
// src/check/mockup-drawing.ts
|
|
7061
7239
|
import { readFileSync as readFileSync31 } from "fs";
|
|
@@ -7229,12 +7407,325 @@ function sizeBlock2(sizes, size) {
|
|
|
7229
7407
|
|
|
7230
7408
|
// src/commands/gate.ts
|
|
7231
7409
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
7410
|
+
|
|
7411
|
+
// src/commands/ship.ts
|
|
7412
|
+
import { existsSync as existsSync24, readdirSync as readdirSync15, readFileSync as readFileSync33 } from "fs";
|
|
7413
|
+
import { join as join40 } from "path";
|
|
7414
|
+
|
|
7415
|
+
// src/commands/seo.ts
|
|
7416
|
+
import { readFileSync as readFileSync32 } from "fs";
|
|
7417
|
+
import { join as join39 } from "path";
|
|
7418
|
+
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
7419
|
+
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
7420
|
+
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
7421
|
+
var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
|
|
7422
|
+
var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
|
|
7423
|
+
var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
|
|
7424
|
+
var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
|
|
7425
|
+
var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
|
|
7426
|
+
function isPage(file, raw) {
|
|
7427
|
+
if (TEST_FILE.test(file)) return false;
|
|
7428
|
+
if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
|
|
7429
|
+
const route = ROUTE_DIR.exec(file);
|
|
7430
|
+
if (route) {
|
|
7431
|
+
const rest = route[1];
|
|
7432
|
+
const name = rest.split("/").pop();
|
|
7433
|
+
if (name.startsWith("+")) return name === "+page.svelte";
|
|
7434
|
+
if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
|
|
7435
|
+
if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
|
|
7436
|
+
if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
|
|
7437
|
+
return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
|
|
7438
|
+
}
|
|
7439
|
+
return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
|
|
7440
|
+
}
|
|
7441
|
+
function routeOf(file) {
|
|
7442
|
+
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(/\/$/, "");
|
|
7443
|
+
return `/${path}`.replace(/\/+/g, "/");
|
|
7444
|
+
}
|
|
7445
|
+
function seo(opts) {
|
|
7446
|
+
const root = opts.projectRoot;
|
|
7447
|
+
const files = selectFiles(root, true).files;
|
|
7448
|
+
const findings = [];
|
|
7449
|
+
const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
|
|
7450
|
+
const pages = [];
|
|
7451
|
+
let sitemapFiles = [];
|
|
7452
|
+
let robotsFiles = [];
|
|
7453
|
+
const sitemapRoutes = /* @__PURE__ */ new Map();
|
|
7454
|
+
const pathLocs = [];
|
|
7455
|
+
for (const file of files) {
|
|
7456
|
+
let raw;
|
|
7457
|
+
try {
|
|
7458
|
+
raw = readFileSync32(join39(root, file), "utf8");
|
|
7459
|
+
} catch {
|
|
7460
|
+
continue;
|
|
7461
|
+
}
|
|
7462
|
+
if (SITEMAP_FILE.test(file)) {
|
|
7463
|
+
sitemapFiles.push(file);
|
|
7464
|
+
for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
|
|
7465
|
+
const value = (match[1] ?? match[2] ?? "").trim();
|
|
7466
|
+
if (!value) continue;
|
|
7467
|
+
if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
|
|
7468
|
+
const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
|
|
7469
|
+
sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
|
|
7470
|
+
}
|
|
7471
|
+
continue;
|
|
7472
|
+
}
|
|
7473
|
+
if (ROBOTS_FILE.test(file)) {
|
|
7474
|
+
robotsFiles.push(file);
|
|
7475
|
+
continue;
|
|
7476
|
+
}
|
|
7477
|
+
if (!isPage(file, raw)) continue;
|
|
7478
|
+
const found = readMetadata(raw);
|
|
7479
|
+
const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
|
|
7480
|
+
pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
|
|
7481
|
+
}
|
|
7482
|
+
for (const page of pages.filter((p) => p.noindex)) {
|
|
7483
|
+
const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
|
|
7484
|
+
if (listed) {
|
|
7485
|
+
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.`);
|
|
7486
|
+
}
|
|
7487
|
+
}
|
|
7488
|
+
if (pathLocs.length > 0) {
|
|
7489
|
+
const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
|
|
7490
|
+
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.`);
|
|
7491
|
+
}
|
|
7492
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
7493
|
+
for (const page of pages) {
|
|
7494
|
+
if (!page.title || page.noindex) continue;
|
|
7495
|
+
byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
|
|
7496
|
+
}
|
|
7497
|
+
for (const [title, where] of byTitle) {
|
|
7498
|
+
if (where.length > 1) {
|
|
7499
|
+
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.`);
|
|
7500
|
+
}
|
|
7501
|
+
}
|
|
7502
|
+
const indexable = pages.filter((p) => !p.noindex);
|
|
7503
|
+
const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
|
|
7504
|
+
if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
|
|
7505
|
+
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.");
|
|
7506
|
+
}
|
|
7507
|
+
const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
|
|
7508
|
+
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}`;
|
|
7509
|
+
return { findings, pages: pages.length, indexable: indexable.length, line };
|
|
7510
|
+
}
|
|
7511
|
+
|
|
7512
|
+
// src/commands/ship.ts
|
|
7513
|
+
var NOT_COVERED = "security, performance, what a screen reader or a real device does, and deployment itself";
|
|
7514
|
+
function specCritique(specBody) {
|
|
7515
|
+
const front = specBody?.split(/^---\s*$/m)[1] ?? "";
|
|
7516
|
+
const value = /^\s*critique\s*:\s*([\w-]+)/im.exec(front)?.[1]?.toLowerCase();
|
|
7517
|
+
return value === "each" || value === "at-ship" ? value : void 0;
|
|
7518
|
+
}
|
|
7519
|
+
function critiqueAtShip(projectRoot, specBody) {
|
|
7520
|
+
const own = specCritique(specBody);
|
|
7521
|
+
if (own) return own === "at-ship";
|
|
7522
|
+
try {
|
|
7523
|
+
return JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
|
|
7524
|
+
} catch {
|
|
7525
|
+
return false;
|
|
7526
|
+
}
|
|
7527
|
+
}
|
|
7528
|
+
function tweakDeferred(dir) {
|
|
7529
|
+
let t;
|
|
7530
|
+
try {
|
|
7531
|
+
t = JSON.parse(readFileSync33(join40(dir, "tweak.json"), "utf8"));
|
|
7532
|
+
} catch {
|
|
7533
|
+
return false;
|
|
7534
|
+
}
|
|
7535
|
+
if (t.deferred === void 0 || t.deferred === false) return false;
|
|
7536
|
+
let judged;
|
|
7537
|
+
try {
|
|
7538
|
+
judged = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).tweak;
|
|
7539
|
+
} catch {
|
|
7540
|
+
}
|
|
7541
|
+
return judged !== t.at;
|
|
7542
|
+
}
|
|
7543
|
+
function pageChecksum(html) {
|
|
7544
|
+
return checksum(html.replace(/(href|src)="[^"]*\.(css|js|mjs)(\?[^"]*)?"/g, '$1="asset.$2"'));
|
|
7545
|
+
}
|
|
7546
|
+
function changedSinceLock(projectRoot, dir) {
|
|
7547
|
+
let lock;
|
|
7548
|
+
try {
|
|
7549
|
+
lock = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8"));
|
|
7550
|
+
} catch {
|
|
7551
|
+
return void 0;
|
|
7552
|
+
}
|
|
7553
|
+
const file = lock.page?.file;
|
|
7554
|
+
if (!file || !lock.page?.checksum) return void 0;
|
|
7555
|
+
try {
|
|
7556
|
+
const html = readFileSync33(join40(projectRoot, file), "utf8");
|
|
7557
|
+
return pageChecksum(html) !== lock.page.checksum && checksum(html) !== lock.page.checksum ? file : void 0;
|
|
7558
|
+
} catch {
|
|
7559
|
+
return void 0;
|
|
7560
|
+
}
|
|
7561
|
+
}
|
|
7562
|
+
function supersededBy(front) {
|
|
7563
|
+
const named = /^\s*superseded_by\s*:\s*(.*)$/im.exec(front)?.[1]?.replace(/\s+#.*$/, "").trim();
|
|
7564
|
+
return named ? named : void 0;
|
|
7565
|
+
}
|
|
7566
|
+
function pageStatus(projectRoot, surface) {
|
|
7567
|
+
const spec = readFileSync33(join40(projectRoot, ".jig", "specs", `${surface}.spec.md`), "utf8");
|
|
7568
|
+
const front = spec.split(/^---\s*$/m)[1] ?? "";
|
|
7569
|
+
if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) {
|
|
7570
|
+
return { surface, state: "in-progress", detail: "its spec is not confirmed, so nothing of it is built to ship" };
|
|
7571
|
+
}
|
|
7572
|
+
const successor = supersededBy(front);
|
|
7573
|
+
if (successor !== void 0) {
|
|
7574
|
+
const file = successor.replace(/(\.spec\.md)?$/, ".spec.md");
|
|
7575
|
+
return existsSync24(join40(projectRoot, ".jig", "specs", file)) ? { surface, state: "superseded", detail: `superseded by ${file}, whose critique judges its page` } : { surface, state: "incomplete", detail: `\`superseded_by: ${successor}\` names a spec that does not exist in .jig/specs` };
|
|
7576
|
+
}
|
|
7577
|
+
const dir = join40(projectRoot, ".jig", "critique", surface);
|
|
7578
|
+
if (!existsSync24(join40(dir, "screen.json")) && !existsSync24(join40(dir, "code.json"))) {
|
|
7579
|
+
return { surface, state: "never", detail: "never critiqued" };
|
|
7580
|
+
}
|
|
7581
|
+
if (tweakDeferred(dir)) return { surface, state: "deferred", detail: "a tweak deferred its re-judge" };
|
|
7582
|
+
const changed = changedSinceLock(projectRoot, dir);
|
|
7583
|
+
if (changed) return { surface, state: "changed", detail: `${changed} changed after it was judged` };
|
|
7584
|
+
const v = verifyVerdicts({ projectRoot, surface });
|
|
7585
|
+
const stale = v.errors.find((e) => /was taken (on|by) an older/.test(e));
|
|
7586
|
+
let judgedPage = false;
|
|
7587
|
+
try {
|
|
7588
|
+
judgedPage = !!JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).page?.checksum;
|
|
7589
|
+
} catch {
|
|
7590
|
+
}
|
|
7591
|
+
if (stale && !judgedPage) return { surface, state: "changed", detail: "the page changed after its probes were taken" };
|
|
7592
|
+
if (stale) return { surface, state: "reprobe", detail: `its page is as judged, but the styles or scripts it loads changed: re-probe it (\`jig verdicts ${surface} --reprobe\`)` };
|
|
7593
|
+
if (!v.ok) return { surface, state: "incomplete", detail: `its critique is not complete: ${v.errors[0]}` };
|
|
7594
|
+
const findings = v.screen.findings + v.code.findings + v.decisions.findings;
|
|
7595
|
+
if (findings > 0) return { surface, state: "findings", detail: `${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` };
|
|
7596
|
+
return { surface, state: "judged", detail: "judged as it stands, nothing open" };
|
|
7597
|
+
}
|
|
7598
|
+
function ship(opts) {
|
|
7599
|
+
const root = opts.projectRoot;
|
|
7600
|
+
const mechanical = check({ projectRoot: root, homeDir: "", version: opts.version, all: true, ci: true });
|
|
7601
|
+
const mechanicalErrors = mechanical.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
|
|
7602
|
+
const seoResult = seo({ projectRoot: root });
|
|
7603
|
+
const seoErrors = seoResult.findings.filter((f) => f.severity === "error").length;
|
|
7604
|
+
const specsDir = join40(root, ".jig", "specs");
|
|
7605
|
+
const surfaces = existsSync24(specsDir) ? readdirSync15(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
|
|
7606
|
+
const pages = surfaces.map((s) => pageStatus(root, s));
|
|
7607
|
+
const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress" && p.state !== "superseded");
|
|
7608
|
+
const ready = mechanicalErrors === 0 && seoErrors === 0 && owed.length === 0;
|
|
7609
|
+
const count = (state) => pages.filter((p) => p.state === state).length;
|
|
7610
|
+
const line = `JIG_SHIP: ready=${ready ? "yes" : "no"} mechanical=${mechanicalErrors} seo=${seoErrors} pages=${pages.length} judged=${count("judged")} owed=${owed.length} in-progress=${count("in-progress")} superseded=${count("superseded")}`;
|
|
7611
|
+
const mark = { judged: "\u2713", never: "\u2717", changed: "\u2717", reprobe: "\u2717", deferred: "\u2717", incomplete: "\u2717", findings: "\u2717", "in-progress": "\xB7", superseded: "\xB7" };
|
|
7612
|
+
const report2 = [
|
|
7613
|
+
` ${mechanicalErrors === 0 ? "\u2713" : "\u2717"} check --all --ci: ${mechanicalErrors} mechanical error${mechanicalErrors === 1 ? "" : "s"}`,
|
|
7614
|
+
` ${seoErrors === 0 ? "\u2713" : "\u2717"} seo: ${seoErrors} error${seoErrors === 1 ? "" : "s"}`,
|
|
7615
|
+
...pages.map((p) => ` ${mark[p.state]} ${p.surface}: ${p.detail}`),
|
|
7616
|
+
owed.length ? ` Critique each page marked \u2717 (\`/jig critique <page>\`); fix what it finds, or record the owner's ruling, and run \`jig ship\` again.` : "",
|
|
7617
|
+
` Not checked by Jig, here or anywhere: ${NOT_COVERED}.`,
|
|
7618
|
+
` ${line}`
|
|
7619
|
+
].filter(Boolean).join("\n");
|
|
7620
|
+
return { ready, mechanicalErrors, seoErrors, pages, line, report: report2 };
|
|
7621
|
+
}
|
|
7622
|
+
|
|
7623
|
+
// src/check/spec-checked.ts
|
|
7624
|
+
import { existsSync as existsSync25, readFileSync as readFileSync34 } from "fs";
|
|
7625
|
+
import { createHash as createHash2 } from "crypto";
|
|
7626
|
+
import { join as join41 } from "path";
|
|
7627
|
+
function specChecksum(body) {
|
|
7628
|
+
return createHash2("sha256").update(body.replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
|
|
7629
|
+
}
|
|
7630
|
+
var mockupLine = (body) => /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
|
|
7631
|
+
function mockupWordProblems(spec, then, owner) {
|
|
7632
|
+
const now = mockupLine(spec.body);
|
|
7633
|
+
if (now === mockupLine(then)) return [];
|
|
7634
|
+
const m = /^(approved|skipped)\b(.*)$/i.exec(now);
|
|
7635
|
+
if (!m) return [];
|
|
7636
|
+
const word = m[1].toLowerCase();
|
|
7637
|
+
const quoted = /["“]([^"”]+)["”]/.exec(m[2])?.[1]?.trim();
|
|
7638
|
+
if (!quoted) {
|
|
7639
|
+
return [`${spec.path}: \`mockup: ${word}\` is recorded without the owner's words. Write \`mockup: ${word} \u2014 "<what they said>"\`, quoting the reply that ${word === "approved" ? "approved it" : "said to skip it"}.`];
|
|
7640
|
+
}
|
|
7641
|
+
if (!quoteHeld(quoted, owner)) {
|
|
7642
|
+
return [`${spec.path}: \`mockup: ${word}\` quotes "${quoted}", which the owner did not say in this session. Quote their reply as they wrote it; ${word === "approved" ? "an approval" : "a skip"} nobody gave is not one.`];
|
|
7643
|
+
}
|
|
7644
|
+
return [];
|
|
7645
|
+
}
|
|
7646
|
+
function mockupPending(body) {
|
|
7647
|
+
const now = mockupLine(body);
|
|
7648
|
+
return !now || /^pending\b/i.test(now);
|
|
7649
|
+
}
|
|
7650
|
+
function ownerQuotes(body) {
|
|
7651
|
+
const quote = String.raw`\\?["“]((?:[^"“”\\\n]|\\(?!"))+?)\\?["”]`;
|
|
7652
|
+
const patterns = [
|
|
7653
|
+
new RegExp(String.raw`\bowner(?:['’]s)?\b[^"“”\n]{0,30}?` + quote, "gi"),
|
|
7654
|
+
new RegExp(String.raw`\buser(?:['’]s words)?\s*(?:(?:said|says|ruled|wrote|asked|answered|replied)\s*[:,]?|:)\s*` + quote, "gi")
|
|
7655
|
+
];
|
|
7656
|
+
const out = [];
|
|
7657
|
+
for (const pattern of patterns) {
|
|
7658
|
+
for (const m of body.matchAll(pattern)) {
|
|
7659
|
+
const q = m[1].trim();
|
|
7660
|
+
if (q.length >= 3 && !/^not given\b/i.test(q) && !out.includes(q)) out.push(q);
|
|
7661
|
+
}
|
|
7662
|
+
}
|
|
7663
|
+
return out;
|
|
7664
|
+
}
|
|
7665
|
+
function specCheckProblems(root, spec, then, owner, decisions) {
|
|
7666
|
+
if (spec.body === then) return [];
|
|
7667
|
+
const problems = [];
|
|
7668
|
+
const held = new Set(ownerQuotes(then));
|
|
7669
|
+
const sources = [owner, then, decisions].join("\n");
|
|
7670
|
+
const unheld = ownerQuotes(spec.body).filter((q) => !held.has(q) && !quoteHeld(q, sources));
|
|
7671
|
+
if (unheld.length) {
|
|
7672
|
+
problems.push(
|
|
7673
|
+
`${spec.path} gives ${unheld.length === 1 ? "a quotation" : `${unheld.length} quotations`} as the owner's that the owner's words do not hold: ${unheld.slice(0, 3).map((q) => `"${q.length > 80 ? `${q.slice(0, 80)}\u2026` : q}"`).join(", ")}. Quote the owner as they said it, or say it in your own words without quotation marks and ask them.`
|
|
7674
|
+
);
|
|
7675
|
+
}
|
|
7676
|
+
const recordPath = `.jig/specs/${spec.slug}.checked.json`;
|
|
7677
|
+
let record;
|
|
7678
|
+
try {
|
|
7679
|
+
record = JSON.parse(readFileSync34(join41(root, recordPath), "utf8"));
|
|
7680
|
+
} catch {
|
|
7681
|
+
problems.push(
|
|
7682
|
+
`${recordPath} is missing. Before the owner is asked to confirm, a reader who did not write the spec checks its quotations against the owner's words and its facts against their sources, and writes that record (step 3c). The owner checks the spec by the sheet it gives them.`
|
|
7683
|
+
);
|
|
7684
|
+
return problems;
|
|
7685
|
+
}
|
|
7686
|
+
if (record.spec !== specChecksum(spec.body)) {
|
|
7687
|
+
problems.push(`${recordPath} is of an earlier spec: the spec changed after it was checked. Have the spec as it stands checked again; the owner confirms what was checked.`);
|
|
7688
|
+
}
|
|
7689
|
+
if (!Array.isArray(record.quotes) || !Array.isArray(record.facts)) {
|
|
7690
|
+
problems.push(`${recordPath} needs \`quotes\` and \`facts\`, each a list: every quotation given as the owner's, and every statement of how something works with the source it was checked against.`);
|
|
7691
|
+
return problems;
|
|
7692
|
+
}
|
|
7693
|
+
const facts = record.facts;
|
|
7694
|
+
const incomplete = facts.filter((f) => typeof f?.claim !== "string" || typeof f.source !== "string" || !f.source.trim() || ![true, false, "unchecked"].includes(f.holds));
|
|
7695
|
+
if (incomplete.length) {
|
|
7696
|
+
problems.push(`${recordPath}: ${incomplete.length} of its facts lack a \`claim\`, a \`source\` or a \`holds\` of true, false or "unchecked".`);
|
|
7697
|
+
}
|
|
7698
|
+
const wrong = facts.filter((f) => f?.holds === false);
|
|
7699
|
+
if (wrong.length) {
|
|
7700
|
+
problems.push(
|
|
7701
|
+
`${spec.path} states ${wrong.length === 1 ? "a fact" : `${wrong.length} facts`} its reader found false at the source: ${wrong.slice(0, 3).map((f) => `"${String(f.claim)}" (${String(f.source)})`).join("; ")}. Fix the spec, then have it checked again, before the owner is asked.`
|
|
7702
|
+
);
|
|
7703
|
+
}
|
|
7704
|
+
const unsaid = facts.filter((f) => f?.holds === "unchecked" && !(typeof f.note === "string" && f.note.trim()));
|
|
7705
|
+
if (unsaid.length) problems.push(`${recordPath}: ${unsaid.length} unchecked ${unsaid.length === 1 ? "fact says" : "facts say"} nothing of why. Say in \`note\` why the source could not be opened; the owner opens it instead.`);
|
|
7706
|
+
const missing = facts.map((f) => typeof f?.source === "string" ? f.source.trim() : "").filter((s) => s && !/^[a-z][a-z0-9+.-]*:\/\//i.test(s)).filter((s) => {
|
|
7707
|
+
const [, file, line] = /^(.+?)(?::(\d+)(?:-\d+)?)?$/.exec(s) ?? [];
|
|
7708
|
+
if (!file || !existsSync25(join41(root, file))) return true;
|
|
7709
|
+
if (!line) return false;
|
|
7710
|
+
try {
|
|
7711
|
+
return Number(line) > readFileSync34(join41(root, file), "utf8").split("\n").length;
|
|
7712
|
+
} catch {
|
|
7713
|
+
return true;
|
|
7714
|
+
}
|
|
7715
|
+
});
|
|
7716
|
+
if (missing.length) {
|
|
7717
|
+
problems.push(`${recordPath} cites ${missing.length === 1 ? "a source" : "sources"} the project does not have: ${missing.slice(0, 4).join(", ")}. A fact is checked at a source that exists; name it as a path from the project root, or a URL.`);
|
|
7718
|
+
}
|
|
7719
|
+
return problems;
|
|
7720
|
+
}
|
|
7721
|
+
|
|
7722
|
+
// src/commands/gate.ts
|
|
7232
7723
|
var MAX_BLOCKS = 3;
|
|
7233
7724
|
function lastJigInvocation(transcriptPath) {
|
|
7234
|
-
if (!transcriptPath || !
|
|
7725
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7235
7726
|
let text;
|
|
7236
7727
|
try {
|
|
7237
|
-
text =
|
|
7728
|
+
text = readFileSync35(transcriptPath, "utf8");
|
|
7238
7729
|
} catch {
|
|
7239
7730
|
return void 0;
|
|
7240
7731
|
}
|
|
@@ -7251,18 +7742,42 @@ function lastJigInvocation(transcriptPath) {
|
|
|
7251
7742
|
const content = entry.message?.content;
|
|
7252
7743
|
const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
|
|
7253
7744
|
for (const t of texts) {
|
|
7254
|
-
const args = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([
|
|
7745
|
+
const args = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([\s\S]*?)<\/command-args>/.exec(t)?.[1] ?? /^\s*\/jig\s+([^\n]*)/.exec(t)?.[1];
|
|
7255
7746
|
const [command, surface] = (args ?? "").trim().split(/\s+/);
|
|
7256
7747
|
if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
|
|
7257
7748
|
}
|
|
7258
7749
|
}
|
|
7259
7750
|
return found;
|
|
7260
7751
|
}
|
|
7752
|
+
function ownerWords(transcriptPath) {
|
|
7753
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return "";
|
|
7754
|
+
const said = [];
|
|
7755
|
+
try {
|
|
7756
|
+
for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
|
|
7757
|
+
if (!line.includes('"user"')) continue;
|
|
7758
|
+
let entry;
|
|
7759
|
+
try {
|
|
7760
|
+
entry = JSON.parse(line);
|
|
7761
|
+
} catch {
|
|
7762
|
+
continue;
|
|
7763
|
+
}
|
|
7764
|
+
if (entry.type !== "user" || entry.isMeta) continue;
|
|
7765
|
+
const content = entry.message?.content;
|
|
7766
|
+
if (typeof content === "string") said.push(content);
|
|
7767
|
+
else if (Array.isArray(content)) {
|
|
7768
|
+
for (const b of content) if (b?.type === "text" && typeof b.text === "string") said.push(b.text);
|
|
7769
|
+
}
|
|
7770
|
+
}
|
|
7771
|
+
} catch {
|
|
7772
|
+
return "";
|
|
7773
|
+
}
|
|
7774
|
+
return said.join("\n");
|
|
7775
|
+
}
|
|
7261
7776
|
function lastAssistantText(transcriptPath) {
|
|
7262
|
-
if (!transcriptPath || !
|
|
7777
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7263
7778
|
let found;
|
|
7264
7779
|
try {
|
|
7265
|
-
for (const line of
|
|
7780
|
+
for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
|
|
7266
7781
|
if (!line.includes('"assistant"')) continue;
|
|
7267
7782
|
let entry;
|
|
7268
7783
|
try {
|
|
@@ -7286,9 +7801,9 @@ function asksOwner(text) {
|
|
|
7286
7801
|
}
|
|
7287
7802
|
var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
|
|
7288
7803
|
function sessionStart(transcriptPath) {
|
|
7289
|
-
if (!transcriptPath || !
|
|
7804
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7290
7805
|
try {
|
|
7291
|
-
for (const line of
|
|
7806
|
+
for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
|
|
7292
7807
|
const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
|
|
7293
7808
|
if (m) {
|
|
7294
7809
|
const t = Date.parse(m[1]);
|
|
@@ -7303,20 +7818,20 @@ function verdictsMtime(dir) {
|
|
|
7303
7818
|
let newest = 0;
|
|
7304
7819
|
for (const name of VERDICT_FILES) {
|
|
7305
7820
|
try {
|
|
7306
|
-
newest = Math.max(newest, statSync5(
|
|
7821
|
+
newest = Math.max(newest, statSync5(join42(dir, name)).mtimeMs);
|
|
7307
7822
|
} catch {
|
|
7308
7823
|
}
|
|
7309
7824
|
}
|
|
7310
7825
|
return newest;
|
|
7311
7826
|
}
|
|
7312
7827
|
function surfacesInPlay(root, command, transcriptPath, surface) {
|
|
7313
|
-
const critiqueDir =
|
|
7314
|
-
if (!
|
|
7315
|
-
const all =
|
|
7828
|
+
const critiqueDir = join42(root, ".jig", "critique");
|
|
7829
|
+
if (!existsSync26(critiqueDir)) return [];
|
|
7830
|
+
const all = readdirSync16(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
7316
7831
|
const start = sessionStart(transcriptPath);
|
|
7317
7832
|
if (start === void 0) return all;
|
|
7318
7833
|
const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
|
|
7319
|
-
return all.filter((s) => s === current || verdictsMtime(
|
|
7834
|
+
return all.filter((s) => s === current || verdictsMtime(join42(critiqueDir, s)) >= start - 1e3);
|
|
7320
7835
|
}
|
|
7321
7836
|
function surfacesToProbe(root, input) {
|
|
7322
7837
|
const invocation = lastJigInvocation(input.transcript_path);
|
|
@@ -7339,7 +7854,7 @@ function changedOutsideRecords(root, start) {
|
|
|
7339
7854
|
const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
|
|
7340
7855
|
const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
|
|
7341
7856
|
try {
|
|
7342
|
-
return start === void 0 || statSync5(
|
|
7857
|
+
return start === void 0 || statSync5(join42(root, f)).mtimeMs >= start - 1e3;
|
|
7343
7858
|
} catch {
|
|
7344
7859
|
return false;
|
|
7345
7860
|
}
|
|
@@ -7355,19 +7870,19 @@ function drawingChangedAfterApproval(root, specPath, at) {
|
|
|
7355
7870
|
const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
|
|
7356
7871
|
if (!approvedIn) return void 0;
|
|
7357
7872
|
const then = git(["show", `${approvedIn}:./${at}`]);
|
|
7358
|
-
return then !==
|
|
7873
|
+
return then !== readFileSync35(join42(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
|
|
7359
7874
|
} catch {
|
|
7360
7875
|
return void 0;
|
|
7361
7876
|
}
|
|
7362
7877
|
}
|
|
7363
|
-
function commandProblems(root, command, surface, start) {
|
|
7878
|
+
function commandProblems(root, command, surface, start, owner = "") {
|
|
7364
7879
|
const problems = [];
|
|
7365
7880
|
const spec = specFor(root, surface);
|
|
7366
7881
|
if (command === "decide") {
|
|
7367
7882
|
const found = decisionsFile(root);
|
|
7368
7883
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
7369
7884
|
else {
|
|
7370
|
-
const body =
|
|
7885
|
+
const body = readFileSync35(join42(root, found), "utf8");
|
|
7371
7886
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
7372
7887
|
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.`");
|
|
7373
7888
|
}
|
|
@@ -7382,6 +7897,10 @@ function commandProblems(root, command, surface, start) {
|
|
|
7382
7897
|
if (problems.length === 0) problems.push(...navProblems(spec));
|
|
7383
7898
|
}
|
|
7384
7899
|
}
|
|
7900
|
+
if (command === "spec" && spec) problems.push(...specChecked(root, spec, start, owner));
|
|
7901
|
+
if ((command === "spec" || command === "mockup" || command === "make") && spec) {
|
|
7902
|
+
problems.push(...mockupWordProblems(spec, fileAtSessionStart(root, spec.path, start), owner));
|
|
7903
|
+
}
|
|
7385
7904
|
if (command === "mockup" && spec) {
|
|
7386
7905
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7387
7906
|
const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
|
|
@@ -7389,12 +7908,12 @@ function commandProblems(root, command, surface, start) {
|
|
|
7389
7908
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7390
7909
|
if (/^approved/i.test(mockup)) {
|
|
7391
7910
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
7392
|
-
else if (!/^https?:/i.test(at) && !
|
|
7911
|
+
else if (!/^https?:/i.test(at) && !existsSync26(join42(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
7393
7912
|
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.`);
|
|
7394
7913
|
}
|
|
7395
|
-
if (at && /\.html?$/i.test(at) &&
|
|
7914
|
+
if (at && /\.html?$/i.test(at) && existsSync26(join42(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
|
|
7396
7915
|
}
|
|
7397
|
-
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
|
|
7916
|
+
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
|
|
7398
7917
|
if ((command === "spec" || command === "make") && spec) {
|
|
7399
7918
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7400
7919
|
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
@@ -7420,7 +7939,7 @@ function commandProblems(root, command, surface, start) {
|
|
|
7420
7939
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7421
7940
|
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
7422
7941
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7423
|
-
if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) &&
|
|
7942
|
+
if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
|
|
7424
7943
|
const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
|
|
7425
7944
|
if (changedAfter) {
|
|
7426
7945
|
problems.push(`${at} has changed since the owner approved it (${changedAfter}). An approval is of the drawing as the owner saw it: set \`mockup: pending\` in ${spec.path} and ask again.`);
|
|
@@ -7432,7 +7951,7 @@ function commandProblems(root, command, surface, start) {
|
|
|
7432
7951
|
if (found) {
|
|
7433
7952
|
let now = "";
|
|
7434
7953
|
try {
|
|
7435
|
-
now =
|
|
7954
|
+
now = readFileSync35(join42(root, found), "utf8");
|
|
7436
7955
|
} catch {
|
|
7437
7956
|
}
|
|
7438
7957
|
const then = fileAtSessionStart(root, found, start);
|
|
@@ -7446,13 +7965,13 @@ function commandProblems(root, command, surface, start) {
|
|
|
7446
7965
|
if (found) {
|
|
7447
7966
|
let now = "";
|
|
7448
7967
|
try {
|
|
7449
|
-
now =
|
|
7968
|
+
now = readFileSync35(join42(root, found), "utf8");
|
|
7450
7969
|
} catch {
|
|
7451
7970
|
}
|
|
7452
7971
|
const then = fileAtSessionStart(root, found, start);
|
|
7453
7972
|
if (now && now !== then) {
|
|
7454
7973
|
problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
|
|
7455
|
-
const change = readTweak(
|
|
7974
|
+
const change = readTweak(join42(root, ".jig", "critique", spec.slug))?.change;
|
|
7456
7975
|
if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
|
|
7457
7976
|
}
|
|
7458
7977
|
}
|
|
@@ -7461,7 +7980,7 @@ function commandProblems(root, command, surface, start) {
|
|
|
7461
7980
|
for (const file of modeFiles(root)) {
|
|
7462
7981
|
let now = "";
|
|
7463
7982
|
try {
|
|
7464
|
-
now =
|
|
7983
|
+
now = readFileSync35(join42(root, file), "utf8");
|
|
7465
7984
|
} catch {
|
|
7466
7985
|
continue;
|
|
7467
7986
|
}
|
|
@@ -7472,8 +7991,8 @@ function commandProblems(root, command, surface, start) {
|
|
|
7472
7991
|
}
|
|
7473
7992
|
}
|
|
7474
7993
|
if (command === "critique") {
|
|
7475
|
-
const dir =
|
|
7476
|
-
const surfaces =
|
|
7994
|
+
const dir = join42(root, ".jig", "critique");
|
|
7995
|
+
const surfaces = existsSync26(dir) ? readdirSync16(dir).filter((s) => existsSync26(join42(dir, s, "screen.json")) || existsSync26(join42(dir, s, "code.json"))) : [];
|
|
7477
7996
|
for (const surface2 of surfaces) {
|
|
7478
7997
|
const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
|
|
7479
7998
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -7484,7 +8003,7 @@ function commandProblems(root, command, surface, start) {
|
|
|
7484
8003
|
}
|
|
7485
8004
|
}
|
|
7486
8005
|
const own = specFor(root, surface)?.slug;
|
|
7487
|
-
if (own && surfaces.includes(own)) problems.push(...reportOmissions(
|
|
8006
|
+
if (own && surfaces.includes(own)) problems.push(...reportOmissions(join42(dir, own), own));
|
|
7488
8007
|
if (surfaces.length === 0) {
|
|
7489
8008
|
problems.push("critique wrote no verdict files. Each reader arm writes .jig/critique/<surface>/screen.json or code.json, and `jig verdicts <surface>` \u2014 not your own count \u2014 decides whether the review is complete. A report without them is not a review.");
|
|
7490
8009
|
}
|
|
@@ -7497,13 +8016,22 @@ function drawingBeforeAsking(root, surface) {
|
|
|
7497
8016
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7498
8017
|
const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7499
8018
|
const at = named || `.jig/mockups/${spec.slug}.html`;
|
|
7500
|
-
if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !
|
|
8019
|
+
if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync26(join42(root, at))) return [];
|
|
7501
8020
|
const found = mockupDrawingProblems(root, spec.body, at);
|
|
7502
8021
|
return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
|
|
7503
8022
|
}
|
|
8023
|
+
function specChecked(root, spec, start, owner) {
|
|
8024
|
+
const found = decisionsFile(root);
|
|
8025
|
+
let decisions = "";
|
|
8026
|
+
try {
|
|
8027
|
+
if (found) decisions = readFileSync35(join42(root, found), "utf8");
|
|
8028
|
+
} catch {
|
|
8029
|
+
}
|
|
8030
|
+
return specCheckProblems(root, spec, fileAtSessionStart(root, spec.path, start), owner, decisions);
|
|
8031
|
+
}
|
|
7504
8032
|
function modeFiles(root) {
|
|
7505
8033
|
try {
|
|
7506
|
-
const state = JSON.parse(
|
|
8034
|
+
const state = JSON.parse(readFileSync35(join42(root, ".jig", "state.json"), "utf8"));
|
|
7507
8035
|
return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
|
|
7508
8036
|
} catch {
|
|
7509
8037
|
return [];
|
|
@@ -7512,7 +8040,7 @@ function modeFiles(root) {
|
|
|
7512
8040
|
function reportOmissions(dir, surface) {
|
|
7513
8041
|
let report2;
|
|
7514
8042
|
try {
|
|
7515
|
-
report2 =
|
|
8043
|
+
report2 = readFileSync35(join42(dir, "REPORT.md"), "utf8").toLowerCase();
|
|
7516
8044
|
} catch {
|
|
7517
8045
|
return [];
|
|
7518
8046
|
}
|
|
@@ -7520,7 +8048,7 @@ function reportOmissions(dir, surface) {
|
|
|
7520
8048
|
for (const f of VERDICT_FILES) {
|
|
7521
8049
|
let file;
|
|
7522
8050
|
try {
|
|
7523
|
-
file = JSON.parse(
|
|
8051
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7524
8052
|
} catch {
|
|
7525
8053
|
continue;
|
|
7526
8054
|
}
|
|
@@ -7533,7 +8061,7 @@ function reportOmissions(dir, surface) {
|
|
|
7533
8061
|
if (!missing.length) return [];
|
|
7534
8062
|
return [`.jig/critique/${surface}/REPORT.md leaves out ${missing.length === 1 ? "a finding" : `${missing.length} findings`} the verdict files hold: ${missing.slice(0, 8).join(", ")}. The report is what the owner and \`make\` read; name every finding in it, by id (a decision by its name).`];
|
|
7535
8063
|
}
|
|
7536
|
-
function tweakProblems(root, spec) {
|
|
8064
|
+
function tweakProblems(root, spec, owner = "") {
|
|
7537
8065
|
const problems = [];
|
|
7538
8066
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7539
8067
|
if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) problems.push(`${spec.path} is not confirmed. A tweak changes a page the owner has confirmed; an unconfirmed spec goes through \`spec\`.`);
|
|
@@ -7541,13 +8069,21 @@ function tweakProblems(root, spec) {
|
|
|
7541
8069
|
if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
|
|
7542
8070
|
else problems.push(...structureSinceApproval(root, spec, front));
|
|
7543
8071
|
const surface = spec.slug;
|
|
7544
|
-
const record = readTweak(
|
|
8072
|
+
const record = readTweak(join42(root, ".jig", "critique", surface));
|
|
7545
8073
|
if (!record) {
|
|
7546
8074
|
problems.push(`.jig/critique/${surface}/tweak.json is missing. It names the change in the owner's words (\`change\`), when it was made (\`at\`) and the rule ids and decisions re-judged for it (\`ids\`).`);
|
|
7547
8075
|
} else {
|
|
7548
8076
|
if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
|
|
7549
8077
|
if (!record.ids.length) problems.push(`.jig/critique/${surface}/tweak.json names nothing to re-judge. A change that no rule and no decision could see needs no tweak; name the ones it can.`);
|
|
7550
|
-
|
|
8078
|
+
if (record.deferred !== void 0) {
|
|
8079
|
+
if (typeof record.deferred === "string" ? !record.deferred.trim() || !quoteHeld(record.deferred.replace(/^["“]|["”]$/g, ""), owner) : !critiqueAtShip(root, spec.body)) {
|
|
8080
|
+
problems.push(
|
|
8081
|
+
`.jig/critique/${surface}/tweak.json defers its re-judge, and nobody said to. \`deferred\` quotes the owner telling you to leave it for later, or is \`true\` where the page's spec says \`critique: at-ship\` (or, the spec silent, jig.config.json says \`"critique": "at-ship"\`). Otherwise re-judge what it names.`
|
|
8082
|
+
);
|
|
8083
|
+
}
|
|
8084
|
+
return problems;
|
|
8085
|
+
}
|
|
8086
|
+
const unjudged = unjudgedTweakIds(join42(root, ".jig", "critique", surface), record);
|
|
7551
8087
|
if (unjudged.length) {
|
|
7552
8088
|
problems.push(
|
|
7553
8089
|
`.jig/critique/${surface}: tweak.json names ${unjudged.slice(0, 6).join(", ")}${unjudged.length > 6 ? " and more" : ""} but no verdict for ${unjudged.length === 1 ? "it" : "them"} carries \`"tweak": "${record.at}"\`. Each named verdict is re-judged on the changed page by a reader that did not make the change.`
|
|
@@ -7583,9 +8119,9 @@ function structureSinceApproval(root, spec, front) {
|
|
|
7583
8119
|
problems.push(`${spec.path}: the regions under \`sizes:\` differ from the ones the page had ${when}. A change to what the page holds is not a tweak: take it through \`spec\` and \`mockup\`.`);
|
|
7584
8120
|
}
|
|
7585
8121
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7586
|
-
if (at && !/^https?:/i.test(at) &&
|
|
8122
|
+
if (at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
|
|
7587
8123
|
try {
|
|
7588
|
-
if (git(["show", `${baseline}:./${at}`]) !==
|
|
8124
|
+
if (git(["show", `${baseline}:./${at}`]) !== readFileSync35(join42(root, at), "utf8")) {
|
|
7589
8125
|
problems.push(`${at} has changed since ${when.replace(/^when /, "")}. A tweak leaves the drawing as approved; a change the drawing must show goes through \`mockup\`.`);
|
|
7590
8126
|
}
|
|
7591
8127
|
} catch {
|
|
@@ -7595,8 +8131,9 @@ function structureSinceApproval(root, spec, front) {
|
|
|
7595
8131
|
}
|
|
7596
8132
|
function readTweak(dir) {
|
|
7597
8133
|
try {
|
|
7598
|
-
const raw = JSON.parse(
|
|
7599
|
-
|
|
8134
|
+
const raw = JSON.parse(readFileSync35(join42(dir, "tweak.json"), "utf8"));
|
|
8135
|
+
const deferred = typeof raw.deferred === "string" || raw.deferred === true ? raw.deferred : void 0;
|
|
8136
|
+
return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [], ...deferred !== void 0 ? { deferred } : {} };
|
|
7600
8137
|
} catch {
|
|
7601
8138
|
return void 0;
|
|
7602
8139
|
}
|
|
@@ -7607,20 +8144,37 @@ function surfacePage(projectRoot, surface) {
|
|
|
7607
8144
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7608
8145
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
7609
8146
|
for (const candidate of candidates) {
|
|
7610
|
-
if (candidate &&
|
|
8147
|
+
if (candidate && existsSync26(join42(projectRoot, candidate))) return candidate;
|
|
7611
8148
|
}
|
|
7612
8149
|
return void 0;
|
|
7613
8150
|
}
|
|
7614
8151
|
function gate(opts) {
|
|
7615
8152
|
const root = opts.projectRoot;
|
|
7616
|
-
if (!
|
|
8153
|
+
if (!existsSync26(join42(root, "jig.config.json")) && !existsSync26(join42(root, ".jig"))) {
|
|
7617
8154
|
return { block: false, reason: "" };
|
|
7618
8155
|
}
|
|
7619
8156
|
const invocation = lastJigInvocation(opts.input.transcript_path);
|
|
7620
8157
|
const command = invocation?.command;
|
|
7621
8158
|
const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
|
|
7622
|
-
const
|
|
8159
|
+
const start = sessionStart(opts.input.transcript_path);
|
|
8160
|
+
const owner = command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
|
|
8161
|
+
const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, start, owner).map((p) => `/jig ${command}: ${p}`) : [];
|
|
7623
8162
|
if (waiting && command === "mockup") problems.push(...drawingBeforeAsking(root, invocation?.surface).map((p) => `/jig mockup: ${p}`));
|
|
8163
|
+
if (command === "ship" && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
|
|
8164
|
+
const result = ship({ projectRoot: root, version: opts.version });
|
|
8165
|
+
if (!result.ready) problems.push(`/jig ship: the project is not ready to ship. Critique what is owed, fix what the critiques find or record the owner's ruling, and run \`jig ship\` again.
|
|
8166
|
+
${result.report}`);
|
|
8167
|
+
}
|
|
8168
|
+
if (command === "make") {
|
|
8169
|
+
const spec = specFor(root, invocation?.surface);
|
|
8170
|
+
if (spec && mockupPending(spec.body) && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
|
|
8171
|
+
problems.push(`/jig make: ${spec.path} still says \`mockup: pending\`: nobody has said whether to draw it. Ask the owner, draw it with \`/jig mockup\` or skip it; if they skip it, write \`mockup: skipped \u2014 "<their words>"\` and build from the spec alone.`);
|
|
8172
|
+
}
|
|
8173
|
+
}
|
|
8174
|
+
if (waiting && command === "spec") {
|
|
8175
|
+
const spec = specFor(root, invocation?.surface);
|
|
8176
|
+
if (spec) problems.push(...specChecked(root, spec, start, owner).map((p) => `/jig spec: ${p}`));
|
|
8177
|
+
}
|
|
7624
8178
|
const selection = selectFiles(root, false);
|
|
7625
8179
|
const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
|
|
7626
8180
|
if (changedUi) {
|
|
@@ -7642,11 +8196,11 @@ function gate(opts) {
|
|
|
7642
8196
|
}
|
|
7643
8197
|
}
|
|
7644
8198
|
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
|
|
7645
|
-
const critiqueDir =
|
|
7646
|
-
if (
|
|
8199
|
+
const critiqueDir = join42(root, ".jig", "critique");
|
|
8200
|
+
if (existsSync26(critiqueDir)) {
|
|
7647
8201
|
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
|
|
7648
|
-
const dir =
|
|
7649
|
-
if (!
|
|
8202
|
+
const dir = join42(critiqueDir, surface);
|
|
8203
|
+
if (!existsSync26(join42(dir, "screen.json")) && !existsSync26(join42(dir, "code.json"))) continue;
|
|
7650
8204
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
7651
8205
|
if (!v.ok) {
|
|
7652
8206
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -7668,11 +8222,11 @@ ${problems.join("\n\n")}`
|
|
|
7668
8222
|
};
|
|
7669
8223
|
}
|
|
7670
8224
|
const session = opts.input.session_id ?? "unknown";
|
|
7671
|
-
const key = `${session}:${
|
|
7672
|
-
const stateFile =
|
|
8225
|
+
const key = `${session}:${createHash3("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
8226
|
+
const stateFile = join42(root, ".jig", "gate.json");
|
|
7673
8227
|
let state = {};
|
|
7674
8228
|
try {
|
|
7675
|
-
state = JSON.parse(
|
|
8229
|
+
state = JSON.parse(readFileSync35(stateFile, "utf8"));
|
|
7676
8230
|
} catch {
|
|
7677
8231
|
}
|
|
7678
8232
|
if (problems.length === 0) {
|
|
@@ -7707,7 +8261,7 @@ function verdictDigests(dir) {
|
|
|
7707
8261
|
for (const f of VERDICT_FILES) {
|
|
7708
8262
|
let file;
|
|
7709
8263
|
try {
|
|
7710
|
-
file = JSON.parse(
|
|
8264
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7711
8265
|
} catch {
|
|
7712
8266
|
continue;
|
|
7713
8267
|
}
|
|
@@ -7718,9 +8272,16 @@ function verdictDigests(dir) {
|
|
|
7718
8272
|
}
|
|
7719
8273
|
return out;
|
|
7720
8274
|
}
|
|
7721
|
-
function writeLock(lockPath, dir, now) {
|
|
8275
|
+
function writeLock(root, surface, lockPath, dir, now) {
|
|
8276
|
+
const pageFile2 = recordedPage(root, surface);
|
|
8277
|
+
let page;
|
|
8278
|
+
try {
|
|
8279
|
+
if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync35(join42(root, pageFile2), "utf8")) };
|
|
8280
|
+
} catch {
|
|
8281
|
+
}
|
|
8282
|
+
const tweakAt = readTweak(dir)?.at;
|
|
7722
8283
|
try {
|
|
7723
|
-
writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir) }) + "\n", "utf8");
|
|
8284
|
+
writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir), ...page ? { page } : {}, ...tweakAt ? { tweak: tweakAt } : {} }) + "\n", "utf8");
|
|
7724
8285
|
} catch {
|
|
7725
8286
|
}
|
|
7726
8287
|
}
|
|
@@ -7728,7 +8289,7 @@ function verdictChecksum(dir) {
|
|
|
7728
8289
|
const parts = VERDICT_FILES.map((f) => {
|
|
7729
8290
|
try {
|
|
7730
8291
|
return `${f}
|
|
7731
|
-
${
|
|
8292
|
+
${readFileSync35(join42(dir, f), "utf8")}`;
|
|
7732
8293
|
} catch {
|
|
7733
8294
|
return "";
|
|
7734
8295
|
}
|
|
@@ -7736,23 +8297,23 @@ ${readFileSync32(join39(dir, f), "utf8")}`;
|
|
|
7736
8297
|
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
7737
8298
|
}
|
|
7738
8299
|
function verdictGuard(root, command, inPlay) {
|
|
7739
|
-
const critiqueDir =
|
|
7740
|
-
if (!
|
|
8300
|
+
const critiqueDir = join42(root, ".jig", "critique");
|
|
8301
|
+
if (!existsSync26(critiqueDir)) return [];
|
|
7741
8302
|
const problems = [];
|
|
7742
|
-
for (const surface of
|
|
8303
|
+
for (const surface of readdirSync16(critiqueDir)) {
|
|
7743
8304
|
if (surface.startsWith("_")) continue;
|
|
7744
|
-
const dir =
|
|
8305
|
+
const dir = join42(critiqueDir, surface);
|
|
7745
8306
|
const now = verdictChecksum(dir);
|
|
7746
8307
|
if (!now) continue;
|
|
7747
|
-
const lockPath =
|
|
7748
|
-
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
7749
|
-
writeLock(lockPath, dir, now);
|
|
8308
|
+
const lockPath = join42(dir, LOCK);
|
|
8309
|
+
if ((command === "critique" || command === "ship") && (!inPlay || inPlay.includes(surface))) {
|
|
8310
|
+
writeLock(root, surface, lockPath, dir, now);
|
|
7750
8311
|
problems.push(...lockLeftBehind(root, surface));
|
|
7751
8312
|
continue;
|
|
7752
8313
|
}
|
|
7753
8314
|
let lock = {};
|
|
7754
8315
|
try {
|
|
7755
|
-
lock = JSON.parse(
|
|
8316
|
+
lock = JSON.parse(readFileSync35(lockPath, "utf8"));
|
|
7756
8317
|
} catch {
|
|
7757
8318
|
continue;
|
|
7758
8319
|
}
|
|
@@ -7761,7 +8322,7 @@ function verdictGuard(root, command, inPlay) {
|
|
|
7761
8322
|
const own = tweakVerdictProblems(dir, surface, lock.verdicts);
|
|
7762
8323
|
problems.push(...own);
|
|
7763
8324
|
if (own.length === 0) {
|
|
7764
|
-
writeLock(lockPath, dir, now);
|
|
8325
|
+
writeLock(root, surface, lockPath, dir, now);
|
|
7765
8326
|
problems.push(...lockLeftBehind(root, surface));
|
|
7766
8327
|
}
|
|
7767
8328
|
continue;
|
|
@@ -7809,7 +8370,7 @@ function unjudgedTweakIds(dir, record) {
|
|
|
7809
8370
|
for (const f of VERDICT_FILES) {
|
|
7810
8371
|
let file;
|
|
7811
8372
|
try {
|
|
7812
|
-
file = JSON.parse(
|
|
8373
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7813
8374
|
} catch {
|
|
7814
8375
|
continue;
|
|
7815
8376
|
}
|
|
@@ -7821,112 +8382,15 @@ function unjudgedTweakIds(dir, record) {
|
|
|
7821
8382
|
}
|
|
7822
8383
|
function save(file, state) {
|
|
7823
8384
|
try {
|
|
7824
|
-
mkdirSync8(
|
|
8385
|
+
mkdirSync8(join42(file, ".."), { recursive: true });
|
|
7825
8386
|
writeFileSync6(file, JSON.stringify(state), "utf8");
|
|
7826
8387
|
} catch {
|
|
7827
8388
|
}
|
|
7828
8389
|
}
|
|
7829
8390
|
|
|
7830
|
-
// src/commands/seo.ts
|
|
7831
|
-
import { readFileSync as readFileSync33 } from "fs";
|
|
7832
|
-
import { join as join40 } from "path";
|
|
7833
|
-
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
7834
|
-
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
7835
|
-
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
7836
|
-
var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
|
|
7837
|
-
var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
|
|
7838
|
-
var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
|
|
7839
|
-
var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
|
|
7840
|
-
var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
|
|
7841
|
-
function isPage(file, raw) {
|
|
7842
|
-
if (TEST_FILE.test(file)) return false;
|
|
7843
|
-
if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
|
|
7844
|
-
const route = ROUTE_DIR.exec(file);
|
|
7845
|
-
if (route) {
|
|
7846
|
-
const rest = route[1];
|
|
7847
|
-
const name = rest.split("/").pop();
|
|
7848
|
-
if (name.startsWith("+")) return name === "+page.svelte";
|
|
7849
|
-
if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
|
|
7850
|
-
if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
|
|
7851
|
-
if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
|
|
7852
|
-
return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
|
|
7853
|
-
}
|
|
7854
|
-
return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
|
|
7855
|
-
}
|
|
7856
|
-
function routeOf(file) {
|
|
7857
|
-
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(/\/$/, "");
|
|
7858
|
-
return `/${path}`.replace(/\/+/g, "/");
|
|
7859
|
-
}
|
|
7860
|
-
function seo(opts) {
|
|
7861
|
-
const root = opts.projectRoot;
|
|
7862
|
-
const files = selectFiles(root, true).files;
|
|
7863
|
-
const findings = [];
|
|
7864
|
-
const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
|
|
7865
|
-
const pages = [];
|
|
7866
|
-
let sitemapFiles = [];
|
|
7867
|
-
let robotsFiles = [];
|
|
7868
|
-
const sitemapRoutes = /* @__PURE__ */ new Map();
|
|
7869
|
-
const pathLocs = [];
|
|
7870
|
-
for (const file of files) {
|
|
7871
|
-
let raw;
|
|
7872
|
-
try {
|
|
7873
|
-
raw = readFileSync33(join40(root, file), "utf8");
|
|
7874
|
-
} catch {
|
|
7875
|
-
continue;
|
|
7876
|
-
}
|
|
7877
|
-
if (SITEMAP_FILE.test(file)) {
|
|
7878
|
-
sitemapFiles.push(file);
|
|
7879
|
-
for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
|
|
7880
|
-
const value = (match[1] ?? match[2] ?? "").trim();
|
|
7881
|
-
if (!value) continue;
|
|
7882
|
-
if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
|
|
7883
|
-
const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
|
|
7884
|
-
sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
|
|
7885
|
-
}
|
|
7886
|
-
continue;
|
|
7887
|
-
}
|
|
7888
|
-
if (ROBOTS_FILE.test(file)) {
|
|
7889
|
-
robotsFiles.push(file);
|
|
7890
|
-
continue;
|
|
7891
|
-
}
|
|
7892
|
-
if (!isPage(file, raw)) continue;
|
|
7893
|
-
const found = readMetadata(raw);
|
|
7894
|
-
const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
|
|
7895
|
-
pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
|
|
7896
|
-
}
|
|
7897
|
-
for (const page of pages.filter((p) => p.noindex)) {
|
|
7898
|
-
const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
|
|
7899
|
-
if (listed) {
|
|
7900
|
-
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.`);
|
|
7901
|
-
}
|
|
7902
|
-
}
|
|
7903
|
-
if (pathLocs.length > 0) {
|
|
7904
|
-
const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
|
|
7905
|
-
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.`);
|
|
7906
|
-
}
|
|
7907
|
-
const byTitle = /* @__PURE__ */ new Map();
|
|
7908
|
-
for (const page of pages) {
|
|
7909
|
-
if (!page.title || page.noindex) continue;
|
|
7910
|
-
byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
|
|
7911
|
-
}
|
|
7912
|
-
for (const [title, where] of byTitle) {
|
|
7913
|
-
if (where.length > 1) {
|
|
7914
|
-
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.`);
|
|
7915
|
-
}
|
|
7916
|
-
}
|
|
7917
|
-
const indexable = pages.filter((p) => !p.noindex);
|
|
7918
|
-
const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
|
|
7919
|
-
if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
|
|
7920
|
-
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.");
|
|
7921
|
-
}
|
|
7922
|
-
const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
|
|
7923
|
-
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}`;
|
|
7924
|
-
return { findings, pages: pages.length, indexable: indexable.length, line };
|
|
7925
|
-
}
|
|
7926
|
-
|
|
7927
8391
|
// src/index.ts
|
|
7928
8392
|
var packageRoot = getPackageRoot();
|
|
7929
|
-
var { version } = JSON.parse(
|
|
8393
|
+
var { version } = JSON.parse(readFileSync36(join43(packageRoot, "package.json"), "utf8"));
|
|
7930
8394
|
function warnIfUnpublishedPin() {
|
|
7931
8395
|
if (isDevVersion(version)) {
|
|
7932
8396
|
console.warn(
|
|
@@ -8107,6 +8571,17 @@ program.command("seo").description("Audit what a search engine and a link previe
|
|
|
8107
8571
|
process.exit(1);
|
|
8108
8572
|
}
|
|
8109
8573
|
});
|
|
8574
|
+
program.command("ship").description("Say whether the project is ready to ship: no mechanical or seo errors, and every confirmed page judged as it stands with nothing open.").action(() => {
|
|
8575
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
8576
|
+
try {
|
|
8577
|
+
const result = ship({ projectRoot, version });
|
|
8578
|
+
console.log(result.report);
|
|
8579
|
+
process.exit(result.ready ? 0 : 1);
|
|
8580
|
+
} catch (err) {
|
|
8581
|
+
console.error(err.message);
|
|
8582
|
+
process.exit(1);
|
|
8583
|
+
}
|
|
8584
|
+
});
|
|
8110
8585
|
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, 1280 and 1600 and either side of each --breakpoint-* the project declares, and record each (needs --save)").option("--serve <dir>", "serve this directory over local http and load --run from it, so root-relative links resolve (a built static site)").action(async (opts) => {
|
|
8111
8586
|
if (opts.run) {
|
|
8112
8587
|
if (!opts.save) {
|
|
@@ -8149,7 +8624,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
8149
8624
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
8150
8625
|
let input = {};
|
|
8151
8626
|
try {
|
|
8152
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
8627
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync36(0, "utf8") || "{}");
|
|
8153
8628
|
} catch {
|
|
8154
8629
|
}
|
|
8155
8630
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -8189,7 +8664,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
8189
8664
|
});
|
|
8190
8665
|
function resolveSurfaceMode(projectRoot) {
|
|
8191
8666
|
try {
|
|
8192
|
-
const config = JSON.parse(
|
|
8667
|
+
const config = JSON.parse(readFileSync36(join43(projectRoot, "jig.config.json"), "utf8"));
|
|
8193
8668
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
8194
8669
|
return modes.length === 1 ? modes[0] : void 0;
|
|
8195
8670
|
} catch {
|