jig-ui 0.20.1 → 0.22.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 +120 -0
- package/README.md +149 -1
- package/dist/index.js +725 -182
- package/package.json +1 -1
- package/templates/COMMAND.md.tmpl +242 -27
- package/templates/SKILL.md.tmpl +5 -0
- 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";
|
|
@@ -40,6 +40,14 @@ function assetRoot(startDir = getPackageRoot()) {
|
|
|
40
40
|
function isPublishedBuild(packageRoot2) {
|
|
41
41
|
return packageRoot2.split(/[\\/]/).includes("node_modules");
|
|
42
42
|
}
|
|
43
|
+
function isDevVersion(version2) {
|
|
44
|
+
return !!version2 && /-dev(\.|$)/.test(version2);
|
|
45
|
+
}
|
|
46
|
+
function cliInvocation(version2) {
|
|
47
|
+
if (!version2) return "npx jig-ui";
|
|
48
|
+
return isDevVersion(version2) ? "jig" : `npx jig-ui@${version2}`;
|
|
49
|
+
}
|
|
50
|
+
var JIG_GATE_HOOK = /(?:\bjig-ui@[^\s"]+|^jig) gate\b/;
|
|
43
51
|
|
|
44
52
|
// src/commands/install.ts
|
|
45
53
|
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -578,7 +586,7 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
|
|
|
578
586
|
);
|
|
579
587
|
return render(template, {
|
|
580
588
|
command_prefix: "/jig ",
|
|
581
|
-
scripts_path: version2
|
|
589
|
+
scripts_path: cliInvocation(version2),
|
|
582
590
|
// `update` must NOT carry the pin. Pinned, it refreshes to the version
|
|
583
591
|
// already installed — reporting "Updated Jig → <same version>" for a no-op
|
|
584
592
|
// — so a reader following the skill could never upgrade.
|
|
@@ -597,7 +605,7 @@ function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
|
|
|
597
605
|
const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
|
|
598
606
|
if (!existsSync5(templatePath)) return { body: "", subcommands };
|
|
599
607
|
const template = readFileSync4(templatePath, "utf8");
|
|
600
|
-
const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g,
|
|
608
|
+
const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, cliInvocation(version2)).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath).replace(/\{\{config_file\}\}/g, "jig.config.json");
|
|
601
609
|
const unresolved = [...new Set(body.match(/\{\{[a-z_]+\}\}/g) ?? [])];
|
|
602
610
|
if (unresolved.length > 0) {
|
|
603
611
|
throw new Error(
|
|
@@ -724,7 +732,8 @@ function install(opts) {
|
|
|
724
732
|
function hasStopHook(projectRoot) {
|
|
725
733
|
try {
|
|
726
734
|
const settings = readFileSync4(join7(projectRoot, ".claude", "settings.json"), "utf8");
|
|
727
|
-
|
|
735
|
+
const hooks = JSON.parse(settings).hooks?.Stop ?? [];
|
|
736
|
+
return hooks.some((group) => (group.hooks ?? []).some((h) => JIG_GATE_HOOK.test(h.command ?? "")));
|
|
728
737
|
} catch {
|
|
729
738
|
return false;
|
|
730
739
|
}
|
|
@@ -739,9 +748,9 @@ function installStopHook(projectRoot, version2) {
|
|
|
739
748
|
return false;
|
|
740
749
|
}
|
|
741
750
|
}
|
|
742
|
-
const command = `npx --yes jig-ui@${version2} gate`;
|
|
751
|
+
const command = isDevVersion(version2) ? "jig gate" : `npx --yes jig-ui@${version2} gate`;
|
|
743
752
|
const hooks = settings.hooks ?? {};
|
|
744
|
-
const isJig = (h) =>
|
|
753
|
+
const isJig = (h) => JIG_GATE_HOOK.test(h.command ?? "");
|
|
745
754
|
const stop = (hooks.Stop ?? []).map((group) => ({ ...group, hooks: group.hooks.filter((h) => !isJig(h)) })).filter((group) => group.hooks.length > 0);
|
|
746
755
|
stop.push({ hooks: [{ type: "command", command }] });
|
|
747
756
|
settings.hooks = { ...hooks, Stop: stop };
|
|
@@ -5340,6 +5349,7 @@ async function init(opts) {
|
|
|
5340
5349
|
log(`Proposed brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%) \u2014 ${proposal.detail}`);
|
|
5341
5350
|
for (const line of describeValidation(validation)) log(line);
|
|
5342
5351
|
let surfaces = DEFAULT_SURFACES;
|
|
5352
|
+
let critique;
|
|
5343
5353
|
if (!opts.yes) {
|
|
5344
5354
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
5345
5355
|
const answer = await prompt(
|
|
@@ -5367,6 +5377,12 @@ async function init(opts) {
|
|
|
5367
5377
|
` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES2.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
|
|
5368
5378
|
);
|
|
5369
5379
|
}
|
|
5380
|
+
const critiqueAnswer = (await prompt(
|
|
5381
|
+
`When should pages be critiqued? Enter to decide page by page, 'each' after every build, or 'ship' to leave them for jig ship: `
|
|
5382
|
+
)).toLowerCase();
|
|
5383
|
+
if (/^(e|each)$/.test(critiqueAnswer)) critique = "each";
|
|
5384
|
+
else if (/^(s|ship|at-ship)$/.test(critiqueAnswer)) critique = "at-ship";
|
|
5385
|
+
else if (critiqueAnswer) log(` Could not read '${critiqueAnswer}' \u2014 leaving it page by page; each spec will ask.`);
|
|
5370
5386
|
}
|
|
5371
5387
|
if (opts.yes) {
|
|
5372
5388
|
const declared = readConfiguredSurfaces(join28(opts.projectRoot, "jig.config.json"));
|
|
@@ -5474,7 +5490,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5474
5490
|
}
|
|
5475
5491
|
}
|
|
5476
5492
|
if (configAction === "written") {
|
|
5477
|
-
const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
|
|
5493
|
+
const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces, ...critique ? { critique } : {} };
|
|
5478
5494
|
const content = `${JSON.stringify(config, null, 2)}
|
|
5479
5495
|
`;
|
|
5480
5496
|
writeFileSync4(configAbsPath, content, "utf8");
|
|
@@ -5490,6 +5506,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5490
5506
|
const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
5491
5507
|
const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
|
|
5492
5508
|
const merged = {
|
|
5509
|
+
...existing,
|
|
5510
|
+
...existing.critique === void 0 && critique ? { critique } : {},
|
|
5493
5511
|
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
5494
5512
|
// Same contract as the 'written' branch: never invent a mapping under
|
|
5495
5513
|
// `--yes`. An existing declaration is always kept.
|
|
@@ -5771,7 +5789,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
|
|
|
5771
5789
|
import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5772
5790
|
|
|
5773
5791
|
// src/probe/script.ts
|
|
5774
|
-
var PROBE_VERSION =
|
|
5792
|
+
var PROBE_VERSION = 9;
|
|
5775
5793
|
var PROBE_SCRIPT = `(async () => {
|
|
5776
5794
|
const doc = document.documentElement;
|
|
5777
5795
|
// checkVisibility also sees content a closed <details> hides: Chromium hides
|
|
@@ -5786,9 +5804,12 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5786
5804
|
const text = document.body.innerText || '';
|
|
5787
5805
|
// I-118: a verbatim quotation keeps its own punctuation. Text marked as a
|
|
5788
5806
|
// quotation is the source's words, so it is taken out before the dash scan;
|
|
5789
|
-
// the page's own copy around it still counts.
|
|
5807
|
+
// the page's own copy around it still counts. So is a tool's output or a
|
|
5808
|
+
// command shown in <pre>, <code>, <samp> or <kbd>: jig-site's home page
|
|
5809
|
+
// quotes \`jig check\`'s real output, whose own wording has an em dash, and
|
|
5810
|
+
// the probe blocked a page for words it had rightly not rewritten.
|
|
5790
5811
|
let ownText = text;
|
|
5791
|
-
for (const q of document.querySelectorAll('blockquote, q')) {
|
|
5812
|
+
for (const q of document.querySelectorAll('blockquote, q, pre, code, samp, kbd')) {
|
|
5792
5813
|
const quoted = (q.innerText || '').trim();
|
|
5793
5814
|
if (quoted) ownText = ownText.split(quoted).join('\\n');
|
|
5794
5815
|
}
|
|
@@ -6557,12 +6578,22 @@ function decisionSections(body) {
|
|
|
6557
6578
|
close();
|
|
6558
6579
|
return sections;
|
|
6559
6580
|
}
|
|
6560
|
-
function
|
|
6581
|
+
function changedWhys(current, before, newOnly = false) {
|
|
6561
6582
|
const then = decisionSections(before);
|
|
6562
|
-
const
|
|
6583
|
+
const paragraphs = (text) => text.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => /^\*\*Why\b[^*]*:\*\*/.test(p));
|
|
6584
|
+
const out = [];
|
|
6563
6585
|
for (const [name, text] of decisionSections(current)) {
|
|
6564
|
-
|
|
6565
|
-
|
|
6586
|
+
const old = then.get(name);
|
|
6587
|
+
if (old === text) continue;
|
|
6588
|
+
const kept = newOnly && old !== void 0 ? new Set(paragraphs(old)) : /* @__PURE__ */ new Set();
|
|
6589
|
+
const whys = paragraphs(text).filter((p) => !kept.has(p));
|
|
6590
|
+
if (whys.length) out.push([name, whys]);
|
|
6591
|
+
}
|
|
6592
|
+
return out;
|
|
6593
|
+
}
|
|
6594
|
+
function unsourcedReasons(current, before, opts = {}) {
|
|
6595
|
+
const problems = [];
|
|
6596
|
+
for (const [name, whys] of changedWhys(current, before, opts.newWhysOnly)) {
|
|
6566
6597
|
for (const why of whys) {
|
|
6567
6598
|
const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
|
|
6568
6599
|
const said = why.replace(/^\s*\*\*Why\b[^*]*:\*\*/, "").trim();
|
|
@@ -6575,6 +6606,26 @@ function unsourcedReasons(current, before) {
|
|
|
6575
6606
|
}
|
|
6576
6607
|
return problems;
|
|
6577
6608
|
}
|
|
6609
|
+
function quotesNotFrom(current, before, words) {
|
|
6610
|
+
const problems = [];
|
|
6611
|
+
for (const [name, whys] of changedWhys(current, before, true)) {
|
|
6612
|
+
for (const why of whys) {
|
|
6613
|
+
const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
|
|
6614
|
+
if (/inferred/i.test(label)) continue;
|
|
6615
|
+
const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
|
|
6616
|
+
if (missing.length) {
|
|
6617
|
+
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):**\`.`);
|
|
6618
|
+
break;
|
|
6619
|
+
}
|
|
6620
|
+
}
|
|
6621
|
+
}
|
|
6622
|
+
return problems;
|
|
6623
|
+
}
|
|
6624
|
+
var normQuote = (t) => t.toLowerCase().replace(/\\(?=["'\\])/g, "").replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, " ").trim();
|
|
6625
|
+
function quoteHeld(quote, words) {
|
|
6626
|
+
const said = normQuote(words);
|
|
6627
|
+
return quote.split(/…|\.\.\./).map((part) => normQuote(part).replace(/^[\s.,;:]+|[\s.,;:!?]+$/g, "")).filter(Boolean).every((part) => said.includes(part));
|
|
6628
|
+
}
|
|
6578
6629
|
|
|
6579
6630
|
// src/check/spec-shape.ts
|
|
6580
6631
|
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
|
|
@@ -6605,6 +6656,10 @@ function specProblems(spec) {
|
|
|
6605
6656
|
if (specIndexableField(front) === "unreadable") {
|
|
6606
6657
|
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.`);
|
|
6607
6658
|
}
|
|
6659
|
+
const critique = /^\s*critique\s*:\s*(.*)$/im.exec(front)?.[1]?.replace(/\s+#.*$/, "").trim();
|
|
6660
|
+
if (critique !== void 0 && !/^(each|at-ship)$/i.test(critique)) {
|
|
6661
|
+
problems.push(`${spec.path}: \`critique: ${critique}\` is neither \`each\` nor \`at-ship\`. Leave it out to follow the project's default.`);
|
|
6662
|
+
}
|
|
6608
6663
|
for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
|
|
6609
6664
|
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
6610
6665
|
}
|
|
@@ -6615,7 +6670,7 @@ function specProblems(spec) {
|
|
|
6615
6670
|
problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
|
|
6616
6671
|
continue;
|
|
6617
6672
|
}
|
|
6618
|
-
if (line[1].trim()) {
|
|
6673
|
+
if (line[1].replace(/(^|\s)#.*$/, "").trim()) {
|
|
6619
6674
|
problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
|
|
6620
6675
|
continue;
|
|
6621
6676
|
}
|
|
@@ -6918,10 +6973,12 @@ function previousFindings(projectRoot, dir) {
|
|
|
6918
6973
|
const files = ["screen.json", "code.json", "decisions.json"];
|
|
6919
6974
|
const paths = files.map((f) => `${rel}/${f}`);
|
|
6920
6975
|
let commit;
|
|
6976
|
+
let target;
|
|
6921
6977
|
try {
|
|
6922
6978
|
const dirty = git(["status", "--porcelain", "--", ...paths]).trim() !== "";
|
|
6923
6979
|
const touched = git(["log", "--format=%H", "-2", "--", ...paths]).split("\n").filter(Boolean);
|
|
6924
6980
|
commit = dirty ? touched[0] ?? "" : touched[1] ?? "";
|
|
6981
|
+
target = dirty ? void 0 : touched[0];
|
|
6925
6982
|
} catch {
|
|
6926
6983
|
return void 0;
|
|
6927
6984
|
}
|
|
@@ -6942,7 +6999,7 @@ function previousFindings(projectRoot, dir) {
|
|
|
6942
6999
|
}
|
|
6943
7000
|
for (const v of Array.isArray(body.verdicts) ? body.verdicts : []) {
|
|
6944
7001
|
const key = String(v.id ?? v.decision ?? "").trim();
|
|
6945
|
-
if (key && typeof v.verdict === "string") out.set(f === "decisions.json" ? `"${key}"` : key.toUpperCase(), v.verdict);
|
|
7002
|
+
if (key && typeof v.verdict === "string") out.set(f === "decisions.json" ? `"${key}"` : key.toUpperCase(), { verdict: v.verdict, reason: String(v.reason ?? "") });
|
|
6946
7003
|
}
|
|
6947
7004
|
return out;
|
|
6948
7005
|
};
|
|
@@ -6952,22 +7009,69 @@ function previousFindings(projectRoot, dir) {
|
|
|
6952
7009
|
for (const [k, v] of read(f, commit)) before.set(k, v);
|
|
6953
7010
|
for (const [k, v] of read(f)) now.set(k, v);
|
|
6954
7011
|
}
|
|
6955
|
-
const
|
|
7012
|
+
const changed = sourceChanges(git, commit, target);
|
|
7013
|
+
const result = { commit: commit.slice(0, 7), fixed: [], unchanged: [], open: [], added: [], ruled: [] };
|
|
6956
7014
|
for (const [k, v] of before) {
|
|
6957
|
-
if (v !== "finding") continue;
|
|
6958
|
-
const after = now.get(k);
|
|
7015
|
+
if (v.verdict !== "finding") continue;
|
|
7016
|
+
const after = now.get(k)?.verdict;
|
|
6959
7017
|
if (after === "finding") result.open.push(k);
|
|
6960
7018
|
else if (after === "ruled") result.ruled.push(k);
|
|
6961
|
-
else if (after === "ok" || after === "n/a") result.fixed.push(k);
|
|
7019
|
+
else if (after === "ok" || after === "n/a") (changed(v.reason) ? result.fixed : result.unchanged).push(k);
|
|
6962
7020
|
}
|
|
6963
|
-
for (const [k, v] of now) if (v === "finding" && before.get(k) !== "finding") result.added.push(k);
|
|
7021
|
+
for (const [k, v] of now) if (v.verdict === "finding" && before.get(k)?.verdict !== "finding") result.added.push(k);
|
|
6964
7022
|
return result;
|
|
6965
7023
|
}
|
|
7024
|
+
var CITATION = /([\w@.\-[\]/]*[\w\]-]\.[a-z][a-z0-9]{0,5})(?::(\d+(?:-\d+)?(?:,\s*\d+(?:-\d+)?)*))?/gi;
|
|
7025
|
+
function sourceChanges(git, from, to) {
|
|
7026
|
+
const range = to ? [from, to] : [from];
|
|
7027
|
+
let tracked = [];
|
|
7028
|
+
let changedFiles = /* @__PURE__ */ new Set();
|
|
7029
|
+
try {
|
|
7030
|
+
tracked = git(["ls-tree", "-r", "--name-only", from]).split("\n").filter((f) => f && !f.startsWith(".jig/"));
|
|
7031
|
+
changedFiles = new Set(git(["diff", "--name-only", ...range, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean));
|
|
7032
|
+
} catch {
|
|
7033
|
+
}
|
|
7034
|
+
const hunks = /* @__PURE__ */ new Map();
|
|
7035
|
+
const oldRanges = (file) => {
|
|
7036
|
+
let out = hunks.get(file);
|
|
7037
|
+
if (out) return out;
|
|
7038
|
+
out = [];
|
|
7039
|
+
try {
|
|
7040
|
+
for (const m of git(["diff", "-U0", ...range, "--", file]).matchAll(/^@@ -(\d+)(?:,(\d+))? /gm)) {
|
|
7041
|
+
const start = Number(m[1]);
|
|
7042
|
+
const count = m[2] === void 0 ? 1 : Number(m[2]);
|
|
7043
|
+
out.push(count === 0 ? [start, start + 1] : [start, start + count - 1]);
|
|
7044
|
+
}
|
|
7045
|
+
} catch {
|
|
7046
|
+
}
|
|
7047
|
+
hunks.set(file, out);
|
|
7048
|
+
return out;
|
|
7049
|
+
};
|
|
7050
|
+
return (reason) => {
|
|
7051
|
+
let cited = false;
|
|
7052
|
+
for (const m of reason.matchAll(CITATION)) {
|
|
7053
|
+
const path = m[1].replace(/^\.\//, "");
|
|
7054
|
+
const matches2 = tracked.filter((f) => f === path || f.endsWith(`/${path}`));
|
|
7055
|
+
if (!matches2.length) continue;
|
|
7056
|
+
cited = true;
|
|
7057
|
+
const lines = (m[2] ?? "").split(",").map((r) => r.trim()).filter(Boolean).map((r) => {
|
|
7058
|
+
const [a, b] = r.split("-").map(Number);
|
|
7059
|
+
return [a, b ?? a];
|
|
7060
|
+
});
|
|
7061
|
+
for (const file of matches2) {
|
|
7062
|
+
if (!changedFiles.has(file)) continue;
|
|
7063
|
+
if (!lines.length) return true;
|
|
7064
|
+
if (oldRanges(file).some(([hs, he]) => lines.some(([s, e]) => hs <= e && he >= s))) return true;
|
|
7065
|
+
}
|
|
7066
|
+
}
|
|
7067
|
+
return cited ? false : changedFiles.size > 0;
|
|
7068
|
+
};
|
|
7069
|
+
}
|
|
6966
7070
|
|
|
6967
7071
|
// src/commands/gate.ts
|
|
6968
|
-
import { existsSync as
|
|
6969
|
-
import { createHash as
|
|
6970
|
-
import { join as
|
|
7072
|
+
import { existsSync as existsSync26, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync35, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
|
|
7073
|
+
import { createHash as createHash3 } from "crypto";
|
|
7074
|
+
import { join as join42 } from "path";
|
|
6971
7075
|
|
|
6972
7076
|
// src/check/mockup-drawing.ts
|
|
6973
7077
|
import { readFileSync as readFileSync31 } from "fs";
|
|
@@ -7053,7 +7157,7 @@ function specSwitches(specBody) {
|
|
|
7053
7157
|
return value.replace(/^\[|\]$/g, "").split(",").map((n) => n.trim().replace(/^["']|["']$/g, "").replace(/^(--)?breakpoint-/, "")).filter(Boolean);
|
|
7054
7158
|
}
|
|
7055
7159
|
function readFrames(html) {
|
|
7056
|
-
const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']
|
|
7160
|
+
const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["']([^"']*)["'][^>]*>/gi)].filter((m) => m[1].split(/\s+/).includes("frame"));
|
|
7057
7161
|
return opens.map((open, i) => {
|
|
7058
7162
|
const tag = open[0];
|
|
7059
7163
|
const body = html.slice(open.index + tag.length, opens[i + 1]?.index ?? html.length);
|
|
@@ -7119,7 +7223,9 @@ function regionDrawn(labels, region) {
|
|
|
7119
7223
|
const found = (name) => labels.some((label) => labelMatches(label, name) || labelMatches(stripQualifiers(label), name));
|
|
7120
7224
|
return found(region.name) || !!region.parts && region.parts.every(found);
|
|
7121
7225
|
}
|
|
7122
|
-
function labelMatches(
|
|
7226
|
+
function labelMatches(rawLabel, rawName) {
|
|
7227
|
+
const label = rawLabel.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
|
|
7228
|
+
const name = rawName.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
|
|
7123
7229
|
return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
|
|
7124
7230
|
}
|
|
7125
7231
|
function normalise(text) {
|
|
@@ -7139,12 +7245,316 @@ function sizeBlock2(sizes, size) {
|
|
|
7139
7245
|
|
|
7140
7246
|
// src/commands/gate.ts
|
|
7141
7247
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
7248
|
+
|
|
7249
|
+
// src/commands/ship.ts
|
|
7250
|
+
import { existsSync as existsSync24, readdirSync as readdirSync15, readFileSync as readFileSync33 } from "fs";
|
|
7251
|
+
import { join as join40 } from "path";
|
|
7252
|
+
|
|
7253
|
+
// src/commands/seo.ts
|
|
7254
|
+
import { readFileSync as readFileSync32 } from "fs";
|
|
7255
|
+
import { join as join39 } from "path";
|
|
7256
|
+
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
7257
|
+
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
7258
|
+
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
7259
|
+
var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
|
|
7260
|
+
var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
|
|
7261
|
+
var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
|
|
7262
|
+
var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
|
|
7263
|
+
var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
|
|
7264
|
+
function isPage(file, raw) {
|
|
7265
|
+
if (TEST_FILE.test(file)) return false;
|
|
7266
|
+
if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
|
|
7267
|
+
const route = ROUTE_DIR.exec(file);
|
|
7268
|
+
if (route) {
|
|
7269
|
+
const rest = route[1];
|
|
7270
|
+
const name = rest.split("/").pop();
|
|
7271
|
+
if (name.startsWith("+")) return name === "+page.svelte";
|
|
7272
|
+
if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
|
|
7273
|
+
if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
|
|
7274
|
+
if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
|
|
7275
|
+
return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
|
|
7276
|
+
}
|
|
7277
|
+
return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
|
|
7278
|
+
}
|
|
7279
|
+
function routeOf(file) {
|
|
7280
|
+
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(/\/$/, "");
|
|
7281
|
+
return `/${path}`.replace(/\/+/g, "/");
|
|
7282
|
+
}
|
|
7283
|
+
function seo(opts) {
|
|
7284
|
+
const root = opts.projectRoot;
|
|
7285
|
+
const files = selectFiles(root, true).files;
|
|
7286
|
+
const findings = [];
|
|
7287
|
+
const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
|
|
7288
|
+
const pages = [];
|
|
7289
|
+
let sitemapFiles = [];
|
|
7290
|
+
let robotsFiles = [];
|
|
7291
|
+
const sitemapRoutes = /* @__PURE__ */ new Map();
|
|
7292
|
+
const pathLocs = [];
|
|
7293
|
+
for (const file of files) {
|
|
7294
|
+
let raw;
|
|
7295
|
+
try {
|
|
7296
|
+
raw = readFileSync32(join39(root, file), "utf8");
|
|
7297
|
+
} catch {
|
|
7298
|
+
continue;
|
|
7299
|
+
}
|
|
7300
|
+
if (SITEMAP_FILE.test(file)) {
|
|
7301
|
+
sitemapFiles.push(file);
|
|
7302
|
+
for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
|
|
7303
|
+
const value = (match[1] ?? match[2] ?? "").trim();
|
|
7304
|
+
if (!value) continue;
|
|
7305
|
+
if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
|
|
7306
|
+
const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
|
|
7307
|
+
sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
|
|
7308
|
+
}
|
|
7309
|
+
continue;
|
|
7310
|
+
}
|
|
7311
|
+
if (ROBOTS_FILE.test(file)) {
|
|
7312
|
+
robotsFiles.push(file);
|
|
7313
|
+
continue;
|
|
7314
|
+
}
|
|
7315
|
+
if (!isPage(file, raw)) continue;
|
|
7316
|
+
const found = readMetadata(raw);
|
|
7317
|
+
const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
|
|
7318
|
+
pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
|
|
7319
|
+
}
|
|
7320
|
+
for (const page of pages.filter((p) => p.noindex)) {
|
|
7321
|
+
const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
|
|
7322
|
+
if (listed) {
|
|
7323
|
+
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.`);
|
|
7324
|
+
}
|
|
7325
|
+
}
|
|
7326
|
+
if (pathLocs.length > 0) {
|
|
7327
|
+
const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
|
|
7328
|
+
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.`);
|
|
7329
|
+
}
|
|
7330
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
7331
|
+
for (const page of pages) {
|
|
7332
|
+
if (!page.title || page.noindex) continue;
|
|
7333
|
+
byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
|
|
7334
|
+
}
|
|
7335
|
+
for (const [title, where] of byTitle) {
|
|
7336
|
+
if (where.length > 1) {
|
|
7337
|
+
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.`);
|
|
7338
|
+
}
|
|
7339
|
+
}
|
|
7340
|
+
const indexable = pages.filter((p) => !p.noindex);
|
|
7341
|
+
const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
|
|
7342
|
+
if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
|
|
7343
|
+
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.");
|
|
7344
|
+
}
|
|
7345
|
+
const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
|
|
7346
|
+
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}`;
|
|
7347
|
+
return { findings, pages: pages.length, indexable: indexable.length, line };
|
|
7348
|
+
}
|
|
7349
|
+
|
|
7350
|
+
// src/commands/ship.ts
|
|
7351
|
+
var NOT_COVERED = "security, performance, what a screen reader or a real device does, and deployment itself";
|
|
7352
|
+
function specCritique(specBody) {
|
|
7353
|
+
const front = specBody?.split(/^---\s*$/m)[1] ?? "";
|
|
7354
|
+
const value = /^\s*critique\s*:\s*([\w-]+)/im.exec(front)?.[1]?.toLowerCase();
|
|
7355
|
+
return value === "each" || value === "at-ship" ? value : void 0;
|
|
7356
|
+
}
|
|
7357
|
+
function critiqueAtShip(projectRoot, specBody) {
|
|
7358
|
+
const own = specCritique(specBody);
|
|
7359
|
+
if (own) return own === "at-ship";
|
|
7360
|
+
try {
|
|
7361
|
+
return JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
|
|
7362
|
+
} catch {
|
|
7363
|
+
return false;
|
|
7364
|
+
}
|
|
7365
|
+
}
|
|
7366
|
+
function tweakDeferred(dir) {
|
|
7367
|
+
let t;
|
|
7368
|
+
try {
|
|
7369
|
+
t = JSON.parse(readFileSync33(join40(dir, "tweak.json"), "utf8"));
|
|
7370
|
+
} catch {
|
|
7371
|
+
return false;
|
|
7372
|
+
}
|
|
7373
|
+
if (t.deferred === void 0 || t.deferred === false) return false;
|
|
7374
|
+
let judged;
|
|
7375
|
+
try {
|
|
7376
|
+
judged = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).tweak;
|
|
7377
|
+
} catch {
|
|
7378
|
+
}
|
|
7379
|
+
return judged !== t.at;
|
|
7380
|
+
}
|
|
7381
|
+
function pageChecksum(html) {
|
|
7382
|
+
return checksum(html.replace(/(href|src)="[^"]*\.(css|js|mjs)(\?[^"]*)?"/g, '$1="asset.$2"'));
|
|
7383
|
+
}
|
|
7384
|
+
function changedSinceLock(projectRoot, dir) {
|
|
7385
|
+
let lock;
|
|
7386
|
+
try {
|
|
7387
|
+
lock = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8"));
|
|
7388
|
+
} catch {
|
|
7389
|
+
return void 0;
|
|
7390
|
+
}
|
|
7391
|
+
const file = lock.page?.file;
|
|
7392
|
+
if (!file || !lock.page?.checksum) return void 0;
|
|
7393
|
+
try {
|
|
7394
|
+
const html = readFileSync33(join40(projectRoot, file), "utf8");
|
|
7395
|
+
return pageChecksum(html) !== lock.page.checksum && checksum(html) !== lock.page.checksum ? file : void 0;
|
|
7396
|
+
} catch {
|
|
7397
|
+
return void 0;
|
|
7398
|
+
}
|
|
7399
|
+
}
|
|
7400
|
+
function pageStatus(projectRoot, surface) {
|
|
7401
|
+
const spec = readFileSync33(join40(projectRoot, ".jig", "specs", `${surface}.spec.md`), "utf8");
|
|
7402
|
+
const front = spec.split(/^---\s*$/m)[1] ?? "";
|
|
7403
|
+
if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) {
|
|
7404
|
+
return { surface, state: "in-progress", detail: "its spec is not confirmed, so nothing of it is built to ship" };
|
|
7405
|
+
}
|
|
7406
|
+
const dir = join40(projectRoot, ".jig", "critique", surface);
|
|
7407
|
+
if (!existsSync24(join40(dir, "screen.json")) && !existsSync24(join40(dir, "code.json"))) {
|
|
7408
|
+
return { surface, state: "never", detail: "never critiqued" };
|
|
7409
|
+
}
|
|
7410
|
+
if (tweakDeferred(dir)) return { surface, state: "deferred", detail: "a tweak deferred its re-judge" };
|
|
7411
|
+
const changed = changedSinceLock(projectRoot, dir);
|
|
7412
|
+
if (changed) return { surface, state: "changed", detail: `${changed} changed after it was judged` };
|
|
7413
|
+
const v = verifyVerdicts({ projectRoot, surface });
|
|
7414
|
+
const stale = v.errors.find((e) => /was taken (on|by) an older/.test(e));
|
|
7415
|
+
let judgedPage = false;
|
|
7416
|
+
try {
|
|
7417
|
+
judgedPage = !!JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).page?.checksum;
|
|
7418
|
+
} catch {
|
|
7419
|
+
}
|
|
7420
|
+
if (stale && !judgedPage) return { surface, state: "changed", detail: "the page changed after its probes were taken" };
|
|
7421
|
+
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\`)` };
|
|
7422
|
+
if (!v.ok) return { surface, state: "incomplete", detail: `its critique is not complete: ${v.errors[0]}` };
|
|
7423
|
+
const findings = v.screen.findings + v.code.findings + v.decisions.findings;
|
|
7424
|
+
if (findings > 0) return { surface, state: "findings", detail: `${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` };
|
|
7425
|
+
return { surface, state: "judged", detail: "judged as it stands, nothing open" };
|
|
7426
|
+
}
|
|
7427
|
+
function ship(opts) {
|
|
7428
|
+
const root = opts.projectRoot;
|
|
7429
|
+
const mechanical = check({ projectRoot: root, homeDir: "", version: opts.version, all: true, ci: true });
|
|
7430
|
+
const mechanicalErrors = mechanical.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
|
|
7431
|
+
const seoResult = seo({ projectRoot: root });
|
|
7432
|
+
const seoErrors = seoResult.findings.filter((f) => f.severity === "error").length;
|
|
7433
|
+
const specsDir = join40(root, ".jig", "specs");
|
|
7434
|
+
const surfaces = existsSync24(specsDir) ? readdirSync15(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
|
|
7435
|
+
const pages = surfaces.map((s) => pageStatus(root, s));
|
|
7436
|
+
const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress");
|
|
7437
|
+
const ready = mechanicalErrors === 0 && seoErrors === 0 && owed.length === 0;
|
|
7438
|
+
const count = (state) => pages.filter((p) => p.state === state).length;
|
|
7439
|
+
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")}`;
|
|
7440
|
+
const mark = { judged: "\u2713", never: "\u2717", changed: "\u2717", reprobe: "\u2717", deferred: "\u2717", incomplete: "\u2717", findings: "\u2717", "in-progress": "\xB7" };
|
|
7441
|
+
const report2 = [
|
|
7442
|
+
` ${mechanicalErrors === 0 ? "\u2713" : "\u2717"} check --all --ci: ${mechanicalErrors} mechanical error${mechanicalErrors === 1 ? "" : "s"}`,
|
|
7443
|
+
` ${seoErrors === 0 ? "\u2713" : "\u2717"} seo: ${seoErrors} error${seoErrors === 1 ? "" : "s"}`,
|
|
7444
|
+
...pages.map((p) => ` ${mark[p.state]} ${p.surface}: ${p.detail}`),
|
|
7445
|
+
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.` : "",
|
|
7446
|
+
` Not checked by Jig, here or anywhere: ${NOT_COVERED}.`,
|
|
7447
|
+
` ${line}`
|
|
7448
|
+
].filter(Boolean).join("\n");
|
|
7449
|
+
return { ready, mechanicalErrors, seoErrors, pages, line, report: report2 };
|
|
7450
|
+
}
|
|
7451
|
+
|
|
7452
|
+
// src/check/spec-checked.ts
|
|
7453
|
+
import { existsSync as existsSync25, readFileSync as readFileSync34 } from "fs";
|
|
7454
|
+
import { createHash as createHash2 } from "crypto";
|
|
7455
|
+
import { join as join41 } from "path";
|
|
7456
|
+
function specChecksum(body) {
|
|
7457
|
+
return createHash2("sha256").update(body.replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
|
|
7458
|
+
}
|
|
7459
|
+
var mockupLine = (body) => /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
|
|
7460
|
+
function mockupWordProblems(spec, then, owner) {
|
|
7461
|
+
const now = mockupLine(spec.body);
|
|
7462
|
+
if (now === mockupLine(then)) return [];
|
|
7463
|
+
const m = /^(approved|skipped)\b(.*)$/i.exec(now);
|
|
7464
|
+
if (!m) return [];
|
|
7465
|
+
const word = m[1].toLowerCase();
|
|
7466
|
+
const quoted = /["“]([^"”]+)["”]/.exec(m[2])?.[1]?.trim();
|
|
7467
|
+
if (!quoted) {
|
|
7468
|
+
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"}.`];
|
|
7469
|
+
}
|
|
7470
|
+
if (!quoteHeld(quoted, owner)) {
|
|
7471
|
+
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.`];
|
|
7472
|
+
}
|
|
7473
|
+
return [];
|
|
7474
|
+
}
|
|
7475
|
+
function mockupPending(body) {
|
|
7476
|
+
const now = mockupLine(body);
|
|
7477
|
+
return !now || /^pending\b/i.test(now);
|
|
7478
|
+
}
|
|
7479
|
+
function ownerQuotes(body) {
|
|
7480
|
+
const quote = String.raw`\\?["“]((?:[^"“”\\\n]|\\(?!"))+?)\\?["”]`;
|
|
7481
|
+
const patterns = [
|
|
7482
|
+
new RegExp(String.raw`\bowner(?:['’]s)?\b[^"“”\n]{0,30}?` + quote, "gi"),
|
|
7483
|
+
new RegExp(String.raw`\buser(?:['’]s words)?\s*(?:(?:said|says|ruled|wrote|asked|answered|replied)\s*[:,]?|:)\s*` + quote, "gi")
|
|
7484
|
+
];
|
|
7485
|
+
const out = [];
|
|
7486
|
+
for (const pattern of patterns) {
|
|
7487
|
+
for (const m of body.matchAll(pattern)) {
|
|
7488
|
+
const q = m[1].trim();
|
|
7489
|
+
if (q.length >= 3 && !/^not given\b/i.test(q) && !out.includes(q)) out.push(q);
|
|
7490
|
+
}
|
|
7491
|
+
}
|
|
7492
|
+
return out;
|
|
7493
|
+
}
|
|
7494
|
+
function specCheckProblems(root, spec, then, owner, decisions) {
|
|
7495
|
+
if (spec.body === then) return [];
|
|
7496
|
+
const problems = [];
|
|
7497
|
+
const held = new Set(ownerQuotes(then));
|
|
7498
|
+
const sources = [owner, then, decisions].join("\n");
|
|
7499
|
+
const unheld = ownerQuotes(spec.body).filter((q) => !held.has(q) && !quoteHeld(q, sources));
|
|
7500
|
+
if (unheld.length) {
|
|
7501
|
+
problems.push(
|
|
7502
|
+
`${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.`
|
|
7503
|
+
);
|
|
7504
|
+
}
|
|
7505
|
+
const recordPath = `.jig/specs/${spec.slug}.checked.json`;
|
|
7506
|
+
let record;
|
|
7507
|
+
try {
|
|
7508
|
+
record = JSON.parse(readFileSync34(join41(root, recordPath), "utf8"));
|
|
7509
|
+
} catch {
|
|
7510
|
+
problems.push(
|
|
7511
|
+
`${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.`
|
|
7512
|
+
);
|
|
7513
|
+
return problems;
|
|
7514
|
+
}
|
|
7515
|
+
if (record.spec !== specChecksum(spec.body)) {
|
|
7516
|
+
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.`);
|
|
7517
|
+
}
|
|
7518
|
+
if (!Array.isArray(record.quotes) || !Array.isArray(record.facts)) {
|
|
7519
|
+
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.`);
|
|
7520
|
+
return problems;
|
|
7521
|
+
}
|
|
7522
|
+
const facts = record.facts;
|
|
7523
|
+
const incomplete = facts.filter((f) => typeof f?.claim !== "string" || typeof f.source !== "string" || !f.source.trim() || ![true, false, "unchecked"].includes(f.holds));
|
|
7524
|
+
if (incomplete.length) {
|
|
7525
|
+
problems.push(`${recordPath}: ${incomplete.length} of its facts lack a \`claim\`, a \`source\` or a \`holds\` of true, false or "unchecked".`);
|
|
7526
|
+
}
|
|
7527
|
+
const wrong = facts.filter((f) => f?.holds === false);
|
|
7528
|
+
if (wrong.length) {
|
|
7529
|
+
problems.push(
|
|
7530
|
+
`${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.`
|
|
7531
|
+
);
|
|
7532
|
+
}
|
|
7533
|
+
const unsaid = facts.filter((f) => f?.holds === "unchecked" && !(typeof f.note === "string" && f.note.trim()));
|
|
7534
|
+
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.`);
|
|
7535
|
+
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) => {
|
|
7536
|
+
const [, file, line] = /^(.+?)(?::(\d+)(?:-\d+)?)?$/.exec(s) ?? [];
|
|
7537
|
+
if (!file || !existsSync25(join41(root, file))) return true;
|
|
7538
|
+
if (!line) return false;
|
|
7539
|
+
try {
|
|
7540
|
+
return Number(line) > readFileSync34(join41(root, file), "utf8").split("\n").length;
|
|
7541
|
+
} catch {
|
|
7542
|
+
return true;
|
|
7543
|
+
}
|
|
7544
|
+
});
|
|
7545
|
+
if (missing.length) {
|
|
7546
|
+
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.`);
|
|
7547
|
+
}
|
|
7548
|
+
return problems;
|
|
7549
|
+
}
|
|
7550
|
+
|
|
7551
|
+
// src/commands/gate.ts
|
|
7142
7552
|
var MAX_BLOCKS = 3;
|
|
7143
7553
|
function lastJigInvocation(transcriptPath) {
|
|
7144
|
-
if (!transcriptPath || !
|
|
7554
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7145
7555
|
let text;
|
|
7146
7556
|
try {
|
|
7147
|
-
text =
|
|
7557
|
+
text = readFileSync35(transcriptPath, "utf8");
|
|
7148
7558
|
} catch {
|
|
7149
7559
|
return void 0;
|
|
7150
7560
|
}
|
|
@@ -7161,18 +7571,42 @@ function lastJigInvocation(transcriptPath) {
|
|
|
7161
7571
|
const content = entry.message?.content;
|
|
7162
7572
|
const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
|
|
7163
7573
|
for (const t of texts) {
|
|
7164
|
-
const args = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([
|
|
7574
|
+
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];
|
|
7165
7575
|
const [command, surface] = (args ?? "").trim().split(/\s+/);
|
|
7166
7576
|
if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
|
|
7167
7577
|
}
|
|
7168
7578
|
}
|
|
7169
7579
|
return found;
|
|
7170
7580
|
}
|
|
7581
|
+
function ownerWords(transcriptPath) {
|
|
7582
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return "";
|
|
7583
|
+
const said = [];
|
|
7584
|
+
try {
|
|
7585
|
+
for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
|
|
7586
|
+
if (!line.includes('"user"')) continue;
|
|
7587
|
+
let entry;
|
|
7588
|
+
try {
|
|
7589
|
+
entry = JSON.parse(line);
|
|
7590
|
+
} catch {
|
|
7591
|
+
continue;
|
|
7592
|
+
}
|
|
7593
|
+
if (entry.type !== "user" || entry.isMeta) continue;
|
|
7594
|
+
const content = entry.message?.content;
|
|
7595
|
+
if (typeof content === "string") said.push(content);
|
|
7596
|
+
else if (Array.isArray(content)) {
|
|
7597
|
+
for (const b of content) if (b?.type === "text" && typeof b.text === "string") said.push(b.text);
|
|
7598
|
+
}
|
|
7599
|
+
}
|
|
7600
|
+
} catch {
|
|
7601
|
+
return "";
|
|
7602
|
+
}
|
|
7603
|
+
return said.join("\n");
|
|
7604
|
+
}
|
|
7171
7605
|
function lastAssistantText(transcriptPath) {
|
|
7172
|
-
if (!transcriptPath || !
|
|
7606
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7173
7607
|
let found;
|
|
7174
7608
|
try {
|
|
7175
|
-
for (const line of
|
|
7609
|
+
for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
|
|
7176
7610
|
if (!line.includes('"assistant"')) continue;
|
|
7177
7611
|
let entry;
|
|
7178
7612
|
try {
|
|
@@ -7196,9 +7630,9 @@ function asksOwner(text) {
|
|
|
7196
7630
|
}
|
|
7197
7631
|
var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
|
|
7198
7632
|
function sessionStart(transcriptPath) {
|
|
7199
|
-
if (!transcriptPath || !
|
|
7633
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7200
7634
|
try {
|
|
7201
|
-
for (const line of
|
|
7635
|
+
for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
|
|
7202
7636
|
const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
|
|
7203
7637
|
if (m) {
|
|
7204
7638
|
const t = Date.parse(m[1]);
|
|
@@ -7213,20 +7647,24 @@ function verdictsMtime(dir) {
|
|
|
7213
7647
|
let newest = 0;
|
|
7214
7648
|
for (const name of VERDICT_FILES) {
|
|
7215
7649
|
try {
|
|
7216
|
-
newest = Math.max(newest, statSync5(
|
|
7650
|
+
newest = Math.max(newest, statSync5(join42(dir, name)).mtimeMs);
|
|
7217
7651
|
} catch {
|
|
7218
7652
|
}
|
|
7219
7653
|
}
|
|
7220
7654
|
return newest;
|
|
7221
7655
|
}
|
|
7222
7656
|
function surfacesInPlay(root, command, transcriptPath, surface) {
|
|
7223
|
-
const critiqueDir =
|
|
7224
|
-
if (!
|
|
7225
|
-
const all =
|
|
7657
|
+
const critiqueDir = join42(root, ".jig", "critique");
|
|
7658
|
+
if (!existsSync26(critiqueDir)) return [];
|
|
7659
|
+
const all = readdirSync16(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
7226
7660
|
const start = sessionStart(transcriptPath);
|
|
7227
7661
|
if (start === void 0) return all;
|
|
7228
7662
|
const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
|
|
7229
|
-
return all.filter((s) => s === current || verdictsMtime(
|
|
7663
|
+
return all.filter((s) => s === current || verdictsMtime(join42(critiqueDir, s)) >= start - 1e3);
|
|
7664
|
+
}
|
|
7665
|
+
function surfacesToProbe(root, input) {
|
|
7666
|
+
const invocation = lastJigInvocation(input.transcript_path);
|
|
7667
|
+
return surfacesInPlay(root, invocation?.command, input.transcript_path, invocation?.surface);
|
|
7230
7668
|
}
|
|
7231
7669
|
function fileAtSessionStart(root, path, start) {
|
|
7232
7670
|
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -7237,19 +7675,48 @@ function fileAtSessionStart(root, path, start) {
|
|
|
7237
7675
|
return "";
|
|
7238
7676
|
}
|
|
7239
7677
|
}
|
|
7240
|
-
function
|
|
7678
|
+
function changedOutsideRecords(root, start) {
|
|
7679
|
+
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
7680
|
+
try {
|
|
7681
|
+
const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
|
|
7682
|
+
if (!base) return [];
|
|
7683
|
+
const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
|
|
7684
|
+
const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
|
|
7685
|
+
try {
|
|
7686
|
+
return start === void 0 || statSync5(join42(root, f)).mtimeMs >= start - 1e3;
|
|
7687
|
+
} catch {
|
|
7688
|
+
return false;
|
|
7689
|
+
}
|
|
7690
|
+
});
|
|
7691
|
+
return [.../* @__PURE__ */ new Set([...changed, ...created])].sort();
|
|
7692
|
+
} catch {
|
|
7693
|
+
return [];
|
|
7694
|
+
}
|
|
7695
|
+
}
|
|
7696
|
+
function drawingChangedAfterApproval(root, specPath, at) {
|
|
7697
|
+
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
7698
|
+
try {
|
|
7699
|
+
const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
|
|
7700
|
+
if (!approvedIn) return void 0;
|
|
7701
|
+
const then = git(["show", `${approvedIn}:./${at}`]);
|
|
7702
|
+
return then !== readFileSync35(join42(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
|
|
7703
|
+
} catch {
|
|
7704
|
+
return void 0;
|
|
7705
|
+
}
|
|
7706
|
+
}
|
|
7707
|
+
function commandProblems(root, command, surface, start, owner = "") {
|
|
7241
7708
|
const problems = [];
|
|
7242
7709
|
const spec = specFor(root, surface);
|
|
7243
7710
|
if (command === "decide") {
|
|
7244
7711
|
const found = decisionsFile(root);
|
|
7245
7712
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
7246
7713
|
else {
|
|
7247
|
-
const body =
|
|
7714
|
+
const body = readFileSync35(join42(root, found), "utf8");
|
|
7248
7715
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
7249
7716
|
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.`");
|
|
7250
7717
|
}
|
|
7251
7718
|
if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
|
|
7252
|
-
problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start)).map((p) => `DECISIONS.md: ${p}`));
|
|
7719
|
+
problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start), { newWhysOnly: true }).map((p) => `DECISIONS.md: ${p}`));
|
|
7253
7720
|
}
|
|
7254
7721
|
}
|
|
7255
7722
|
if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
|
|
@@ -7259,6 +7726,10 @@ function commandProblems(root, command, surface, start) {
|
|
|
7259
7726
|
if (problems.length === 0) problems.push(...navProblems(spec));
|
|
7260
7727
|
}
|
|
7261
7728
|
}
|
|
7729
|
+
if (command === "spec" && spec) problems.push(...specChecked(root, spec, start, owner));
|
|
7730
|
+
if ((command === "spec" || command === "mockup" || command === "make") && spec) {
|
|
7731
|
+
problems.push(...mockupWordProblems(spec, fileAtSessionStart(root, spec.path, start), owner));
|
|
7732
|
+
}
|
|
7262
7733
|
if (command === "mockup" && spec) {
|
|
7263
7734
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7264
7735
|
const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
|
|
@@ -7266,12 +7737,12 @@ function commandProblems(root, command, surface, start) {
|
|
|
7266
7737
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7267
7738
|
if (/^approved/i.test(mockup)) {
|
|
7268
7739
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
7269
|
-
else if (!/^https?:/i.test(at) && !
|
|
7740
|
+
else if (!/^https?:/i.test(at) && !existsSync26(join42(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
7270
7741
|
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.`);
|
|
7271
7742
|
}
|
|
7272
|
-
if (at && /\.html?$/i.test(at) &&
|
|
7743
|
+
if (at && /\.html?$/i.test(at) && existsSync26(join42(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
|
|
7273
7744
|
}
|
|
7274
|
-
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
|
|
7745
|
+
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
|
|
7275
7746
|
if ((command === "spec" || command === "make") && spec) {
|
|
7276
7747
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7277
7748
|
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
@@ -7285,12 +7756,31 @@ function commandProblems(root, command, surface, start) {
|
|
|
7285
7756
|
}
|
|
7286
7757
|
}
|
|
7287
7758
|
}
|
|
7759
|
+
if (command === "spec" || command === "mockup" || command === "critique") {
|
|
7760
|
+
const outside = changedOutsideRecords(root, start);
|
|
7761
|
+
if (outside.length) {
|
|
7762
|
+
problems.push(
|
|
7763
|
+
`${command} writes under .jig/ only, and this session changed ${outside.slice(0, 6).join(", ")}${outside.length > 6 ? ` and ${outside.length - 6} more` : ""}. Put them back as they were when the session began, and hand the change to \`/jig make\` (or \`/jig tweak\` for a small one to a built page). ` + (command === "critique" ? "A critique that changes the page it judges is grading its own work; report the finding instead." : "The build is make's.")
|
|
7764
|
+
);
|
|
7765
|
+
}
|
|
7766
|
+
}
|
|
7767
|
+
if (command === "mockup" && spec) {
|
|
7768
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7769
|
+
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
7770
|
+
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7771
|
+
if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
|
|
7772
|
+
const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
|
|
7773
|
+
if (changedAfter) {
|
|
7774
|
+
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.`);
|
|
7775
|
+
}
|
|
7776
|
+
}
|
|
7777
|
+
}
|
|
7288
7778
|
if (command === "make") {
|
|
7289
7779
|
const found = decisionsFile(root);
|
|
7290
7780
|
if (found) {
|
|
7291
7781
|
let now = "";
|
|
7292
7782
|
try {
|
|
7293
|
-
now =
|
|
7783
|
+
now = readFileSync35(join42(root, found), "utf8");
|
|
7294
7784
|
} catch {
|
|
7295
7785
|
}
|
|
7296
7786
|
const then = fileAtSessionStart(root, found, start);
|
|
@@ -7299,9 +7789,39 @@ function commandProblems(root, command, surface, start) {
|
|
|
7299
7789
|
}
|
|
7300
7790
|
}
|
|
7301
7791
|
}
|
|
7792
|
+
if (command === "tweak" && spec) {
|
|
7793
|
+
const found = decisionsFile(root);
|
|
7794
|
+
if (found) {
|
|
7795
|
+
let now = "";
|
|
7796
|
+
try {
|
|
7797
|
+
now = readFileSync35(join42(root, found), "utf8");
|
|
7798
|
+
} catch {
|
|
7799
|
+
}
|
|
7800
|
+
const then = fileAtSessionStart(root, found, start);
|
|
7801
|
+
if (now && now !== then) {
|
|
7802
|
+
problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
|
|
7803
|
+
const change = readTweak(join42(root, ".jig", "critique", spec.slug))?.change;
|
|
7804
|
+
if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
|
|
7805
|
+
}
|
|
7806
|
+
}
|
|
7807
|
+
}
|
|
7808
|
+
if (command !== "update" && command !== "init" && command !== "install") {
|
|
7809
|
+
for (const file of modeFiles(root)) {
|
|
7810
|
+
let now = "";
|
|
7811
|
+
try {
|
|
7812
|
+
now = readFileSync35(join42(root, file), "utf8");
|
|
7813
|
+
} catch {
|
|
7814
|
+
continue;
|
|
7815
|
+
}
|
|
7816
|
+
const then = fileAtSessionStart(root, file, start);
|
|
7817
|
+
if (then && now !== then) {
|
|
7818
|
+
problems.push(`${file} changed in this session. A mode file is Jig's, refreshed by \`update\`; a value this project needs of its own goes in its brand file or its own stylesheet. Restore it (\`git checkout -- ${file}\`, or \`git show <commit>:${file}\` if the change is committed).`);
|
|
7819
|
+
}
|
|
7820
|
+
}
|
|
7821
|
+
}
|
|
7302
7822
|
if (command === "critique") {
|
|
7303
|
-
const dir =
|
|
7304
|
-
const surfaces =
|
|
7823
|
+
const dir = join42(root, ".jig", "critique");
|
|
7824
|
+
const surfaces = existsSync26(dir) ? readdirSync16(dir).filter((s) => existsSync26(join42(dir, s, "screen.json")) || existsSync26(join42(dir, s, "code.json"))) : [];
|
|
7305
7825
|
for (const surface2 of surfaces) {
|
|
7306
7826
|
const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
|
|
7307
7827
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -7311,13 +7831,66 @@ function commandProblems(root, command, surface, start) {
|
|
|
7311
7831
|
problems.push(`${surface2}: 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.`);
|
|
7312
7832
|
}
|
|
7313
7833
|
}
|
|
7834
|
+
const own = specFor(root, surface)?.slug;
|
|
7835
|
+
if (own && surfaces.includes(own)) problems.push(...reportOmissions(join42(dir, own), own));
|
|
7314
7836
|
if (surfaces.length === 0) {
|
|
7315
7837
|
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.");
|
|
7316
7838
|
}
|
|
7317
7839
|
}
|
|
7318
7840
|
return problems;
|
|
7319
7841
|
}
|
|
7320
|
-
function
|
|
7842
|
+
function drawingBeforeAsking(root, surface) {
|
|
7843
|
+
const spec = specFor(root, surface);
|
|
7844
|
+
if (!spec) return [];
|
|
7845
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7846
|
+
const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7847
|
+
const at = named || `.jig/mockups/${spec.slug}.html`;
|
|
7848
|
+
if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync26(join42(root, at))) return [];
|
|
7849
|
+
const found = mockupDrawingProblems(root, spec.body, at);
|
|
7850
|
+
return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
|
|
7851
|
+
}
|
|
7852
|
+
function specChecked(root, spec, start, owner) {
|
|
7853
|
+
const found = decisionsFile(root);
|
|
7854
|
+
let decisions = "";
|
|
7855
|
+
try {
|
|
7856
|
+
if (found) decisions = readFileSync35(join42(root, found), "utf8");
|
|
7857
|
+
} catch {
|
|
7858
|
+
}
|
|
7859
|
+
return specCheckProblems(root, spec, fileAtSessionStart(root, spec.path, start), owner, decisions);
|
|
7860
|
+
}
|
|
7861
|
+
function modeFiles(root) {
|
|
7862
|
+
try {
|
|
7863
|
+
const state = JSON.parse(readFileSync35(join42(root, ".jig", "state.json"), "utf8"));
|
|
7864
|
+
return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
|
|
7865
|
+
} catch {
|
|
7866
|
+
return [];
|
|
7867
|
+
}
|
|
7868
|
+
}
|
|
7869
|
+
function reportOmissions(dir, surface) {
|
|
7870
|
+
let report2;
|
|
7871
|
+
try {
|
|
7872
|
+
report2 = readFileSync35(join42(dir, "REPORT.md"), "utf8").toLowerCase();
|
|
7873
|
+
} catch {
|
|
7874
|
+
return [];
|
|
7875
|
+
}
|
|
7876
|
+
const missing = [];
|
|
7877
|
+
for (const f of VERDICT_FILES) {
|
|
7878
|
+
let file;
|
|
7879
|
+
try {
|
|
7880
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7881
|
+
} catch {
|
|
7882
|
+
continue;
|
|
7883
|
+
}
|
|
7884
|
+
for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
|
|
7885
|
+
if (v.verdict !== "finding") continue;
|
|
7886
|
+
const key = String(v.id ?? v.decision ?? "").trim();
|
|
7887
|
+
if (key && !report2.includes(key.toLowerCase())) missing.push(v.id ? key : `"${key}"`);
|
|
7888
|
+
}
|
|
7889
|
+
}
|
|
7890
|
+
if (!missing.length) return [];
|
|
7891
|
+
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).`];
|
|
7892
|
+
}
|
|
7893
|
+
function tweakProblems(root, spec, owner = "") {
|
|
7321
7894
|
const problems = [];
|
|
7322
7895
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7323
7896
|
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\`.`);
|
|
@@ -7325,13 +7898,21 @@ function tweakProblems(root, spec) {
|
|
|
7325
7898
|
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\`.`);
|
|
7326
7899
|
else problems.push(...structureSinceApproval(root, spec, front));
|
|
7327
7900
|
const surface = spec.slug;
|
|
7328
|
-
const record = readTweak(
|
|
7901
|
+
const record = readTweak(join42(root, ".jig", "critique", surface));
|
|
7329
7902
|
if (!record) {
|
|
7330
7903
|
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\`).`);
|
|
7331
7904
|
} else {
|
|
7332
7905
|
if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
|
|
7333
7906
|
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.`);
|
|
7334
|
-
|
|
7907
|
+
if (record.deferred !== void 0) {
|
|
7908
|
+
if (typeof record.deferred === "string" ? !record.deferred.trim() || !quoteHeld(record.deferred.replace(/^["“]|["”]$/g, ""), owner) : !critiqueAtShip(root, spec.body)) {
|
|
7909
|
+
problems.push(
|
|
7910
|
+
`.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.`
|
|
7911
|
+
);
|
|
7912
|
+
}
|
|
7913
|
+
return problems;
|
|
7914
|
+
}
|
|
7915
|
+
const unjudged = unjudgedTweakIds(join42(root, ".jig", "critique", surface), record);
|
|
7335
7916
|
if (unjudged.length) {
|
|
7336
7917
|
problems.push(
|
|
7337
7918
|
`.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.`
|
|
@@ -7367,9 +7948,9 @@ function structureSinceApproval(root, spec, front) {
|
|
|
7367
7948
|
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\`.`);
|
|
7368
7949
|
}
|
|
7369
7950
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7370
|
-
if (at && !/^https?:/i.test(at) &&
|
|
7951
|
+
if (at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
|
|
7371
7952
|
try {
|
|
7372
|
-
if (git(["show", `${baseline}:./${at}`]) !==
|
|
7953
|
+
if (git(["show", `${baseline}:./${at}`]) !== readFileSync35(join42(root, at), "utf8")) {
|
|
7373
7954
|
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\`.`);
|
|
7374
7955
|
}
|
|
7375
7956
|
} catch {
|
|
@@ -7379,8 +7960,9 @@ function structureSinceApproval(root, spec, front) {
|
|
|
7379
7960
|
}
|
|
7380
7961
|
function readTweak(dir) {
|
|
7381
7962
|
try {
|
|
7382
|
-
const raw = JSON.parse(
|
|
7383
|
-
|
|
7963
|
+
const raw = JSON.parse(readFileSync35(join42(dir, "tweak.json"), "utf8"));
|
|
7964
|
+
const deferred = typeof raw.deferred === "string" || raw.deferred === true ? raw.deferred : void 0;
|
|
7965
|
+
return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [], ...deferred !== void 0 ? { deferred } : {} };
|
|
7384
7966
|
} catch {
|
|
7385
7967
|
return void 0;
|
|
7386
7968
|
}
|
|
@@ -7391,19 +7973,37 @@ function surfacePage(projectRoot, surface) {
|
|
|
7391
7973
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7392
7974
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
7393
7975
|
for (const candidate of candidates) {
|
|
7394
|
-
if (candidate &&
|
|
7976
|
+
if (candidate && existsSync26(join42(projectRoot, candidate))) return candidate;
|
|
7395
7977
|
}
|
|
7396
7978
|
return void 0;
|
|
7397
7979
|
}
|
|
7398
7980
|
function gate(opts) {
|
|
7399
7981
|
const root = opts.projectRoot;
|
|
7400
|
-
if (!
|
|
7982
|
+
if (!existsSync26(join42(root, "jig.config.json")) && !existsSync26(join42(root, ".jig"))) {
|
|
7401
7983
|
return { block: false, reason: "" };
|
|
7402
7984
|
}
|
|
7403
7985
|
const invocation = lastJigInvocation(opts.input.transcript_path);
|
|
7404
7986
|
const command = invocation?.command;
|
|
7405
7987
|
const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
|
|
7406
|
-
const
|
|
7988
|
+
const start = sessionStart(opts.input.transcript_path);
|
|
7989
|
+
const owner = command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
|
|
7990
|
+
const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, start, owner).map((p) => `/jig ${command}: ${p}`) : [];
|
|
7991
|
+
if (waiting && command === "mockup") problems.push(...drawingBeforeAsking(root, invocation?.surface).map((p) => `/jig mockup: ${p}`));
|
|
7992
|
+
if (command === "ship" && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
|
|
7993
|
+
const result = ship({ projectRoot: root, version: opts.version });
|
|
7994
|
+
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.
|
|
7995
|
+
${result.report}`);
|
|
7996
|
+
}
|
|
7997
|
+
if (command === "make") {
|
|
7998
|
+
const spec = specFor(root, invocation?.surface);
|
|
7999
|
+
if (spec && mockupPending(spec.body) && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
|
|
8000
|
+
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.`);
|
|
8001
|
+
}
|
|
8002
|
+
}
|
|
8003
|
+
if (waiting && command === "spec") {
|
|
8004
|
+
const spec = specFor(root, invocation?.surface);
|
|
8005
|
+
if (spec) problems.push(...specChecked(root, spec, start, owner).map((p) => `/jig spec: ${p}`));
|
|
8006
|
+
}
|
|
7407
8007
|
const selection = selectFiles(root, false);
|
|
7408
8008
|
const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
|
|
7409
8009
|
if (changedUi) {
|
|
@@ -7425,11 +8025,11 @@ function gate(opts) {
|
|
|
7425
8025
|
}
|
|
7426
8026
|
}
|
|
7427
8027
|
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
|
|
7428
|
-
const critiqueDir =
|
|
7429
|
-
if (
|
|
8028
|
+
const critiqueDir = join42(root, ".jig", "critique");
|
|
8029
|
+
if (existsSync26(critiqueDir)) {
|
|
7430
8030
|
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
|
|
7431
|
-
const dir =
|
|
7432
|
-
if (!
|
|
8031
|
+
const dir = join42(critiqueDir, surface);
|
|
8032
|
+
if (!existsSync26(join42(dir, "screen.json")) && !existsSync26(join42(dir, "code.json"))) continue;
|
|
7433
8033
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
7434
8034
|
if (!v.ok) {
|
|
7435
8035
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -7451,11 +8051,11 @@ ${problems.join("\n\n")}`
|
|
|
7451
8051
|
};
|
|
7452
8052
|
}
|
|
7453
8053
|
const session = opts.input.session_id ?? "unknown";
|
|
7454
|
-
const key = `${session}:${
|
|
7455
|
-
const stateFile =
|
|
8054
|
+
const key = `${session}:${createHash3("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
8055
|
+
const stateFile = join42(root, ".jig", "gate.json");
|
|
7456
8056
|
let state = {};
|
|
7457
8057
|
try {
|
|
7458
|
-
state = JSON.parse(
|
|
8058
|
+
state = JSON.parse(readFileSync35(stateFile, "utf8"));
|
|
7459
8059
|
} catch {
|
|
7460
8060
|
}
|
|
7461
8061
|
if (problems.length === 0) {
|
|
@@ -7490,7 +8090,7 @@ function verdictDigests(dir) {
|
|
|
7490
8090
|
for (const f of VERDICT_FILES) {
|
|
7491
8091
|
let file;
|
|
7492
8092
|
try {
|
|
7493
|
-
file = JSON.parse(
|
|
8093
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7494
8094
|
} catch {
|
|
7495
8095
|
continue;
|
|
7496
8096
|
}
|
|
@@ -7501,9 +8101,16 @@ function verdictDigests(dir) {
|
|
|
7501
8101
|
}
|
|
7502
8102
|
return out;
|
|
7503
8103
|
}
|
|
7504
|
-
function writeLock(lockPath, dir, now) {
|
|
8104
|
+
function writeLock(root, surface, lockPath, dir, now) {
|
|
8105
|
+
const pageFile2 = recordedPage(root, surface);
|
|
8106
|
+
let page;
|
|
7505
8107
|
try {
|
|
7506
|
-
|
|
8108
|
+
if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync35(join42(root, pageFile2), "utf8")) };
|
|
8109
|
+
} catch {
|
|
8110
|
+
}
|
|
8111
|
+
const tweakAt = readTweak(dir)?.at;
|
|
8112
|
+
try {
|
|
8113
|
+
writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir), ...page ? { page } : {}, ...tweakAt ? { tweak: tweakAt } : {} }) + "\n", "utf8");
|
|
7507
8114
|
} catch {
|
|
7508
8115
|
}
|
|
7509
8116
|
}
|
|
@@ -7511,7 +8118,7 @@ function verdictChecksum(dir) {
|
|
|
7511
8118
|
const parts = VERDICT_FILES.map((f) => {
|
|
7512
8119
|
try {
|
|
7513
8120
|
return `${f}
|
|
7514
|
-
${
|
|
8121
|
+
${readFileSync35(join42(dir, f), "utf8")}`;
|
|
7515
8122
|
} catch {
|
|
7516
8123
|
return "";
|
|
7517
8124
|
}
|
|
@@ -7519,22 +8126,23 @@ ${readFileSync32(join39(dir, f), "utf8")}`;
|
|
|
7519
8126
|
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
7520
8127
|
}
|
|
7521
8128
|
function verdictGuard(root, command, inPlay) {
|
|
7522
|
-
const critiqueDir =
|
|
7523
|
-
if (!
|
|
8129
|
+
const critiqueDir = join42(root, ".jig", "critique");
|
|
8130
|
+
if (!existsSync26(critiqueDir)) return [];
|
|
7524
8131
|
const problems = [];
|
|
7525
|
-
for (const surface of
|
|
8132
|
+
for (const surface of readdirSync16(critiqueDir)) {
|
|
7526
8133
|
if (surface.startsWith("_")) continue;
|
|
7527
|
-
const dir =
|
|
8134
|
+
const dir = join42(critiqueDir, surface);
|
|
7528
8135
|
const now = verdictChecksum(dir);
|
|
7529
8136
|
if (!now) continue;
|
|
7530
|
-
const lockPath =
|
|
7531
|
-
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
7532
|
-
writeLock(lockPath, dir, now);
|
|
8137
|
+
const lockPath = join42(dir, LOCK);
|
|
8138
|
+
if ((command === "critique" || command === "ship") && (!inPlay || inPlay.includes(surface))) {
|
|
8139
|
+
writeLock(root, surface, lockPath, dir, now);
|
|
8140
|
+
problems.push(...lockLeftBehind(root, surface));
|
|
7533
8141
|
continue;
|
|
7534
8142
|
}
|
|
7535
8143
|
let lock = {};
|
|
7536
8144
|
try {
|
|
7537
|
-
lock = JSON.parse(
|
|
8145
|
+
lock = JSON.parse(readFileSync35(lockPath, "utf8"));
|
|
7538
8146
|
} catch {
|
|
7539
8147
|
continue;
|
|
7540
8148
|
}
|
|
@@ -7542,7 +8150,10 @@ function verdictGuard(root, command, inPlay) {
|
|
|
7542
8150
|
if (command === "tweak" && (!inPlay || inPlay.includes(surface)) && locked !== now) {
|
|
7543
8151
|
const own = tweakVerdictProblems(dir, surface, lock.verdicts);
|
|
7544
8152
|
problems.push(...own);
|
|
7545
|
-
if (own.length === 0)
|
|
8153
|
+
if (own.length === 0) {
|
|
8154
|
+
writeLock(root, surface, lockPath, dir, now);
|
|
8155
|
+
problems.push(...lockLeftBehind(root, surface));
|
|
8156
|
+
}
|
|
7546
8157
|
continue;
|
|
7547
8158
|
}
|
|
7548
8159
|
if (locked && locked !== now) {
|
|
@@ -7553,6 +8164,17 @@ function verdictGuard(root, command, inPlay) {
|
|
|
7553
8164
|
}
|
|
7554
8165
|
return problems;
|
|
7555
8166
|
}
|
|
8167
|
+
function lockLeftBehind(root, surface) {
|
|
8168
|
+
const rel = `.jig/critique/${surface}`;
|
|
8169
|
+
try {
|
|
8170
|
+
const status = (paths) => execFileSync4("git", ["status", "--porcelain", "--", ...paths], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
8171
|
+
if (!status([`${rel}/${LOCK}`])) return [];
|
|
8172
|
+
if (status(VERDICT_FILES.map((f) => `${rel}/${f}`))) return [];
|
|
8173
|
+
} catch {
|
|
8174
|
+
return [];
|
|
8175
|
+
}
|
|
8176
|
+
return [`${rel}/${LOCK} was written when you stopped, after the verdicts it records were committed. Commit it on its own (\`git add ${rel}/${LOCK} && git commit -m "chore: record ${surface}'s verdict lock"\`), and nothing else.`];
|
|
8177
|
+
}
|
|
7556
8178
|
function tweakVerdictProblems(dir, surface, lockedVerdicts) {
|
|
7557
8179
|
const record = readTweak(dir);
|
|
7558
8180
|
if (!record) {
|
|
@@ -7577,7 +8199,7 @@ function unjudgedTweakIds(dir, record) {
|
|
|
7577
8199
|
for (const f of VERDICT_FILES) {
|
|
7578
8200
|
let file;
|
|
7579
8201
|
try {
|
|
7580
|
-
file = JSON.parse(
|
|
8202
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7581
8203
|
} catch {
|
|
7582
8204
|
continue;
|
|
7583
8205
|
}
|
|
@@ -7589,113 +8211,22 @@ function unjudgedTweakIds(dir, record) {
|
|
|
7589
8211
|
}
|
|
7590
8212
|
function save(file, state) {
|
|
7591
8213
|
try {
|
|
7592
|
-
mkdirSync8(
|
|
8214
|
+
mkdirSync8(join42(file, ".."), { recursive: true });
|
|
7593
8215
|
writeFileSync6(file, JSON.stringify(state), "utf8");
|
|
7594
8216
|
} catch {
|
|
7595
8217
|
}
|
|
7596
8218
|
}
|
|
7597
8219
|
|
|
7598
|
-
// src/commands/seo.ts
|
|
7599
|
-
import { readFileSync as readFileSync33 } from "fs";
|
|
7600
|
-
import { join as join40 } from "path";
|
|
7601
|
-
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
7602
|
-
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
7603
|
-
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
7604
|
-
var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
|
|
7605
|
-
var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
|
|
7606
|
-
var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
|
|
7607
|
-
var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
|
|
7608
|
-
var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
|
|
7609
|
-
function isPage(file, raw) {
|
|
7610
|
-
if (TEST_FILE.test(file)) return false;
|
|
7611
|
-
if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
|
|
7612
|
-
const route = ROUTE_DIR.exec(file);
|
|
7613
|
-
if (route) {
|
|
7614
|
-
const rest = route[1];
|
|
7615
|
-
const name = rest.split("/").pop();
|
|
7616
|
-
if (name.startsWith("+")) return name === "+page.svelte";
|
|
7617
|
-
if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
|
|
7618
|
-
if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
|
|
7619
|
-
if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
|
|
7620
|
-
return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
|
|
7621
|
-
}
|
|
7622
|
-
return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
|
|
7623
|
-
}
|
|
7624
|
-
function routeOf(file) {
|
|
7625
|
-
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(/\/$/, "");
|
|
7626
|
-
return `/${path}`.replace(/\/+/g, "/");
|
|
7627
|
-
}
|
|
7628
|
-
function seo(opts) {
|
|
7629
|
-
const root = opts.projectRoot;
|
|
7630
|
-
const files = selectFiles(root, true).files;
|
|
7631
|
-
const findings = [];
|
|
7632
|
-
const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
|
|
7633
|
-
const pages = [];
|
|
7634
|
-
let sitemapFiles = [];
|
|
7635
|
-
let robotsFiles = [];
|
|
7636
|
-
const sitemapRoutes = /* @__PURE__ */ new Map();
|
|
7637
|
-
const pathLocs = [];
|
|
7638
|
-
for (const file of files) {
|
|
7639
|
-
let raw;
|
|
7640
|
-
try {
|
|
7641
|
-
raw = readFileSync33(join40(root, file), "utf8");
|
|
7642
|
-
} catch {
|
|
7643
|
-
continue;
|
|
7644
|
-
}
|
|
7645
|
-
if (SITEMAP_FILE.test(file)) {
|
|
7646
|
-
sitemapFiles.push(file);
|
|
7647
|
-
for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
|
|
7648
|
-
const value = (match[1] ?? match[2] ?? "").trim();
|
|
7649
|
-
if (!value) continue;
|
|
7650
|
-
if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
|
|
7651
|
-
const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
|
|
7652
|
-
sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
|
|
7653
|
-
}
|
|
7654
|
-
continue;
|
|
7655
|
-
}
|
|
7656
|
-
if (ROBOTS_FILE.test(file)) {
|
|
7657
|
-
robotsFiles.push(file);
|
|
7658
|
-
continue;
|
|
7659
|
-
}
|
|
7660
|
-
if (!isPage(file, raw)) continue;
|
|
7661
|
-
const found = readMetadata(raw);
|
|
7662
|
-
const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
|
|
7663
|
-
pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
|
|
7664
|
-
}
|
|
7665
|
-
for (const page of pages.filter((p) => p.noindex)) {
|
|
7666
|
-
const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
|
|
7667
|
-
if (listed) {
|
|
7668
|
-
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.`);
|
|
7669
|
-
}
|
|
7670
|
-
}
|
|
7671
|
-
if (pathLocs.length > 0) {
|
|
7672
|
-
const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
|
|
7673
|
-
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.`);
|
|
7674
|
-
}
|
|
7675
|
-
const byTitle = /* @__PURE__ */ new Map();
|
|
7676
|
-
for (const page of pages) {
|
|
7677
|
-
if (!page.title || page.noindex) continue;
|
|
7678
|
-
byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
|
|
7679
|
-
}
|
|
7680
|
-
for (const [title, where] of byTitle) {
|
|
7681
|
-
if (where.length > 1) {
|
|
7682
|
-
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.`);
|
|
7683
|
-
}
|
|
7684
|
-
}
|
|
7685
|
-
const indexable = pages.filter((p) => !p.noindex);
|
|
7686
|
-
const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
|
|
7687
|
-
if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
|
|
7688
|
-
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.");
|
|
7689
|
-
}
|
|
7690
|
-
const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
|
|
7691
|
-
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}`;
|
|
7692
|
-
return { findings, pages: pages.length, indexable: indexable.length, line };
|
|
7693
|
-
}
|
|
7694
|
-
|
|
7695
8220
|
// src/index.ts
|
|
7696
8221
|
var packageRoot = getPackageRoot();
|
|
7697
|
-
var { version } = JSON.parse(
|
|
8222
|
+
var { version } = JSON.parse(readFileSync36(join43(packageRoot, "package.json"), "utf8"));
|
|
7698
8223
|
function warnIfUnpublishedPin() {
|
|
8224
|
+
if (isDevVersion(version)) {
|
|
8225
|
+
console.warn(
|
|
8226
|
+
`Dev build ${version}: the skill, its commands and the Stop hook run the \`jig\` on your PATH, not npm, which has no such version. Keep this build installed while the project uses it, and install a published version to go back.`
|
|
8227
|
+
);
|
|
8228
|
+
return;
|
|
8229
|
+
}
|
|
7699
8230
|
if (isPublishedBuild(packageRoot)) return;
|
|
7700
8231
|
console.warn(
|
|
7701
8232
|
`Note: this is a source build, so the skill pins 'npx jig-ui@${version}' \u2014 a version that may not be published. Agents reading it will not be able to run the CLI until it is. Re-run install or update from a published build to correct the pin.`
|
|
@@ -7819,7 +8350,7 @@ program.command("verdicts").description("Verify a critique's verdict files and c
|
|
|
7819
8350
|
const p = result.previous;
|
|
7820
8351
|
if (p) {
|
|
7821
8352
|
const list = (ids) => ids.length ? ` (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ", \u2026" : ""})` : "";
|
|
7822
|
-
console.log(` Since the critique before this one (${p.commit}): ${p.fixed.length} fixed${list(p.fixed)}, ${p.open.length} still open${list(p.open)}, ${p.ruled.length} ruled by the owner${list(p.ruled)}, ${p.added.length} new${list(p.added)}.`);
|
|
8353
|
+
console.log(` Since the critique before this one (${p.commit}): ${p.fixed.length} fixed${list(p.fixed)}, ${p.unchanged.length} judged ok though the lines they cited did not change${list(p.unchanged)}, ${p.open.length} still open${list(p.open)}, ${p.ruled.length} ruled by the owner${list(p.ruled)}, ${p.added.length} new${list(p.added)}.`);
|
|
7823
8354
|
}
|
|
7824
8355
|
console.log(` ${result.line}`);
|
|
7825
8356
|
process.exit(result.ok ? 0 : 1);
|
|
@@ -7869,6 +8400,17 @@ program.command("seo").description("Audit what a search engine and a link previe
|
|
|
7869
8400
|
process.exit(1);
|
|
7870
8401
|
}
|
|
7871
8402
|
});
|
|
8403
|
+
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(() => {
|
|
8404
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
8405
|
+
try {
|
|
8406
|
+
const result = ship({ projectRoot, version });
|
|
8407
|
+
console.log(result.report);
|
|
8408
|
+
process.exit(result.ready ? 0 : 1);
|
|
8409
|
+
} catch (err) {
|
|
8410
|
+
console.error(err.message);
|
|
8411
|
+
process.exit(1);
|
|
8412
|
+
}
|
|
8413
|
+
});
|
|
7872
8414
|
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) => {
|
|
7873
8415
|
if (opts.run) {
|
|
7874
8416
|
if (!opts.save) {
|
|
@@ -7911,13 +8453,14 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
7911
8453
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
7912
8454
|
let input = {};
|
|
7913
8455
|
try {
|
|
7914
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
8456
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync36(0, "utf8") || "{}");
|
|
7915
8457
|
} catch {
|
|
7916
8458
|
}
|
|
7917
8459
|
const cwd = input.cwd ?? process.cwd();
|
|
7918
8460
|
try {
|
|
7919
8461
|
const projectRoot = findProjectRoot(cwd);
|
|
7920
|
-
|
|
8462
|
+
const critiqued = critiquedSurfaces(projectRoot);
|
|
8463
|
+
for (const surface of surfacesToProbe(projectRoot, input).filter((s) => critiqued.includes(s))) {
|
|
7921
8464
|
const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
|
|
7922
8465
|
if (!page) continue;
|
|
7923
8466
|
try {
|
|
@@ -7950,7 +8493,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
7950
8493
|
});
|
|
7951
8494
|
function resolveSurfaceMode(projectRoot) {
|
|
7952
8495
|
try {
|
|
7953
|
-
const config = JSON.parse(
|
|
8496
|
+
const config = JSON.parse(readFileSync36(join43(projectRoot, "jig.config.json"), "utf8"));
|
|
7954
8497
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
7955
8498
|
return modes.length === 1 ? modes[0] : void 0;
|
|
7956
8499
|
} catch {
|