jig-ui 0.21.0 → 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 +50 -0
- package/README.md +149 -1
- package/dist/index.js +476 -172
- package/package.json +1 -1
- package/templates/COMMAND.md.tmpl +215 -21
- 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";
|
|
@@ -5349,6 +5349,7 @@ async function init(opts) {
|
|
|
5349
5349
|
log(`Proposed brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%) \u2014 ${proposal.detail}`);
|
|
5350
5350
|
for (const line of describeValidation(validation)) log(line);
|
|
5351
5351
|
let surfaces = DEFAULT_SURFACES;
|
|
5352
|
+
let critique;
|
|
5352
5353
|
if (!opts.yes) {
|
|
5353
5354
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
5354
5355
|
const answer = await prompt(
|
|
@@ -5376,6 +5377,12 @@ async function init(opts) {
|
|
|
5376
5377
|
` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES2.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
|
|
5377
5378
|
);
|
|
5378
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.`);
|
|
5379
5386
|
}
|
|
5380
5387
|
if (opts.yes) {
|
|
5381
5388
|
const declared = readConfiguredSurfaces(join28(opts.projectRoot, "jig.config.json"));
|
|
@@ -5483,7 +5490,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5483
5490
|
}
|
|
5484
5491
|
}
|
|
5485
5492
|
if (configAction === "written") {
|
|
5486
|
-
const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
|
|
5493
|
+
const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces, ...critique ? { critique } : {} };
|
|
5487
5494
|
const content = `${JSON.stringify(config, null, 2)}
|
|
5488
5495
|
`;
|
|
5489
5496
|
writeFileSync4(configAbsPath, content, "utf8");
|
|
@@ -5499,6 +5506,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5499
5506
|
const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
5500
5507
|
const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
|
|
5501
5508
|
const merged = {
|
|
5509
|
+
...existing,
|
|
5510
|
+
...existing.critique === void 0 && critique ? { critique } : {},
|
|
5502
5511
|
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
5503
5512
|
// Same contract as the 'written' branch: never invent a mapping under
|
|
5504
5513
|
// `--yes`. An existing declaration is always kept.
|
|
@@ -6598,14 +6607,12 @@ function unsourcedReasons(current, before, opts = {}) {
|
|
|
6598
6607
|
return problems;
|
|
6599
6608
|
}
|
|
6600
6609
|
function quotesNotFrom(current, before, words) {
|
|
6601
|
-
const norm = (t) => t.toLowerCase().replace(/[‘’]/g, "'").replace(/\s+/g, " ").trim();
|
|
6602
|
-
const said = norm(words);
|
|
6603
6610
|
const problems = [];
|
|
6604
6611
|
for (const [name, whys] of changedWhys(current, before, true)) {
|
|
6605
6612
|
for (const why of whys) {
|
|
6606
6613
|
const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
|
|
6607
6614
|
if (/inferred/i.test(label)) continue;
|
|
6608
|
-
const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => q
|
|
6615
|
+
const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
|
|
6609
6616
|
if (missing.length) {
|
|
6610
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):**\`.`);
|
|
6611
6618
|
break;
|
|
@@ -6614,6 +6621,11 @@ function quotesNotFrom(current, before, words) {
|
|
|
6614
6621
|
}
|
|
6615
6622
|
return problems;
|
|
6616
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
|
+
}
|
|
6617
6629
|
|
|
6618
6630
|
// src/check/spec-shape.ts
|
|
6619
6631
|
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
|
|
@@ -6644,6 +6656,10 @@ function specProblems(spec) {
|
|
|
6644
6656
|
if (specIndexableField(front) === "unreadable") {
|
|
6645
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.`);
|
|
6646
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
|
+
}
|
|
6647
6663
|
for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
|
|
6648
6664
|
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
6649
6665
|
}
|
|
@@ -7053,9 +7069,9 @@ function sourceChanges(git, from, to) {
|
|
|
7053
7069
|
}
|
|
7054
7070
|
|
|
7055
7071
|
// src/commands/gate.ts
|
|
7056
|
-
import { existsSync as
|
|
7057
|
-
import { createHash as
|
|
7058
|
-
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";
|
|
7059
7075
|
|
|
7060
7076
|
// src/check/mockup-drawing.ts
|
|
7061
7077
|
import { readFileSync as readFileSync31 } from "fs";
|
|
@@ -7229,12 +7245,316 @@ function sizeBlock2(sizes, size) {
|
|
|
7229
7245
|
|
|
7230
7246
|
// src/commands/gate.ts
|
|
7231
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
|
|
7232
7552
|
var MAX_BLOCKS = 3;
|
|
7233
7553
|
function lastJigInvocation(transcriptPath) {
|
|
7234
|
-
if (!transcriptPath || !
|
|
7554
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7235
7555
|
let text;
|
|
7236
7556
|
try {
|
|
7237
|
-
text =
|
|
7557
|
+
text = readFileSync35(transcriptPath, "utf8");
|
|
7238
7558
|
} catch {
|
|
7239
7559
|
return void 0;
|
|
7240
7560
|
}
|
|
@@ -7251,18 +7571,42 @@ function lastJigInvocation(transcriptPath) {
|
|
|
7251
7571
|
const content = entry.message?.content;
|
|
7252
7572
|
const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
|
|
7253
7573
|
for (const t of texts) {
|
|
7254
|
-
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];
|
|
7255
7575
|
const [command, surface] = (args ?? "").trim().split(/\s+/);
|
|
7256
7576
|
if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
|
|
7257
7577
|
}
|
|
7258
7578
|
}
|
|
7259
7579
|
return found;
|
|
7260
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
|
+
}
|
|
7261
7605
|
function lastAssistantText(transcriptPath) {
|
|
7262
|
-
if (!transcriptPath || !
|
|
7606
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7263
7607
|
let found;
|
|
7264
7608
|
try {
|
|
7265
|
-
for (const line of
|
|
7609
|
+
for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
|
|
7266
7610
|
if (!line.includes('"assistant"')) continue;
|
|
7267
7611
|
let entry;
|
|
7268
7612
|
try {
|
|
@@ -7286,9 +7630,9 @@ function asksOwner(text) {
|
|
|
7286
7630
|
}
|
|
7287
7631
|
var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
|
|
7288
7632
|
function sessionStart(transcriptPath) {
|
|
7289
|
-
if (!transcriptPath || !
|
|
7633
|
+
if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
|
|
7290
7634
|
try {
|
|
7291
|
-
for (const line of
|
|
7635
|
+
for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
|
|
7292
7636
|
const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
|
|
7293
7637
|
if (m) {
|
|
7294
7638
|
const t = Date.parse(m[1]);
|
|
@@ -7303,20 +7647,20 @@ function verdictsMtime(dir) {
|
|
|
7303
7647
|
let newest = 0;
|
|
7304
7648
|
for (const name of VERDICT_FILES) {
|
|
7305
7649
|
try {
|
|
7306
|
-
newest = Math.max(newest, statSync5(
|
|
7650
|
+
newest = Math.max(newest, statSync5(join42(dir, name)).mtimeMs);
|
|
7307
7651
|
} catch {
|
|
7308
7652
|
}
|
|
7309
7653
|
}
|
|
7310
7654
|
return newest;
|
|
7311
7655
|
}
|
|
7312
7656
|
function surfacesInPlay(root, command, transcriptPath, surface) {
|
|
7313
|
-
const critiqueDir =
|
|
7314
|
-
if (!
|
|
7315
|
-
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);
|
|
7316
7660
|
const start = sessionStart(transcriptPath);
|
|
7317
7661
|
if (start === void 0) return all;
|
|
7318
7662
|
const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
|
|
7319
|
-
return all.filter((s) => s === current || verdictsMtime(
|
|
7663
|
+
return all.filter((s) => s === current || verdictsMtime(join42(critiqueDir, s)) >= start - 1e3);
|
|
7320
7664
|
}
|
|
7321
7665
|
function surfacesToProbe(root, input) {
|
|
7322
7666
|
const invocation = lastJigInvocation(input.transcript_path);
|
|
@@ -7339,7 +7683,7 @@ function changedOutsideRecords(root, start) {
|
|
|
7339
7683
|
const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
|
|
7340
7684
|
const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
|
|
7341
7685
|
try {
|
|
7342
|
-
return start === void 0 || statSync5(
|
|
7686
|
+
return start === void 0 || statSync5(join42(root, f)).mtimeMs >= start - 1e3;
|
|
7343
7687
|
} catch {
|
|
7344
7688
|
return false;
|
|
7345
7689
|
}
|
|
@@ -7355,19 +7699,19 @@ function drawingChangedAfterApproval(root, specPath, at) {
|
|
|
7355
7699
|
const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
|
|
7356
7700
|
if (!approvedIn) return void 0;
|
|
7357
7701
|
const then = git(["show", `${approvedIn}:./${at}`]);
|
|
7358
|
-
return then !==
|
|
7702
|
+
return then !== readFileSync35(join42(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
|
|
7359
7703
|
} catch {
|
|
7360
7704
|
return void 0;
|
|
7361
7705
|
}
|
|
7362
7706
|
}
|
|
7363
|
-
function commandProblems(root, command, surface, start) {
|
|
7707
|
+
function commandProblems(root, command, surface, start, owner = "") {
|
|
7364
7708
|
const problems = [];
|
|
7365
7709
|
const spec = specFor(root, surface);
|
|
7366
7710
|
if (command === "decide") {
|
|
7367
7711
|
const found = decisionsFile(root);
|
|
7368
7712
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
7369
7713
|
else {
|
|
7370
|
-
const body =
|
|
7714
|
+
const body = readFileSync35(join42(root, found), "utf8");
|
|
7371
7715
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
7372
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.`");
|
|
7373
7717
|
}
|
|
@@ -7382,6 +7726,10 @@ function commandProblems(root, command, surface, start) {
|
|
|
7382
7726
|
if (problems.length === 0) problems.push(...navProblems(spec));
|
|
7383
7727
|
}
|
|
7384
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
|
+
}
|
|
7385
7733
|
if (command === "mockup" && spec) {
|
|
7386
7734
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7387
7735
|
const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
|
|
@@ -7389,12 +7737,12 @@ function commandProblems(root, command, surface, start) {
|
|
|
7389
7737
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7390
7738
|
if (/^approved/i.test(mockup)) {
|
|
7391
7739
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
7392
|
-
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.`);
|
|
7393
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.`);
|
|
7394
7742
|
}
|
|
7395
|
-
if (at && /\.html?$/i.test(at) &&
|
|
7743
|
+
if (at && /\.html?$/i.test(at) && existsSync26(join42(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
|
|
7396
7744
|
}
|
|
7397
|
-
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
|
|
7745
|
+
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
|
|
7398
7746
|
if ((command === "spec" || command === "make") && spec) {
|
|
7399
7747
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7400
7748
|
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
@@ -7420,7 +7768,7 @@ function commandProblems(root, command, surface, start) {
|
|
|
7420
7768
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7421
7769
|
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
7422
7770
|
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) &&
|
|
7771
|
+
if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
|
|
7424
7772
|
const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
|
|
7425
7773
|
if (changedAfter) {
|
|
7426
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.`);
|
|
@@ -7432,7 +7780,7 @@ function commandProblems(root, command, surface, start) {
|
|
|
7432
7780
|
if (found) {
|
|
7433
7781
|
let now = "";
|
|
7434
7782
|
try {
|
|
7435
|
-
now =
|
|
7783
|
+
now = readFileSync35(join42(root, found), "utf8");
|
|
7436
7784
|
} catch {
|
|
7437
7785
|
}
|
|
7438
7786
|
const then = fileAtSessionStart(root, found, start);
|
|
@@ -7446,13 +7794,13 @@ function commandProblems(root, command, surface, start) {
|
|
|
7446
7794
|
if (found) {
|
|
7447
7795
|
let now = "";
|
|
7448
7796
|
try {
|
|
7449
|
-
now =
|
|
7797
|
+
now = readFileSync35(join42(root, found), "utf8");
|
|
7450
7798
|
} catch {
|
|
7451
7799
|
}
|
|
7452
7800
|
const then = fileAtSessionStart(root, found, start);
|
|
7453
7801
|
if (now && now !== then) {
|
|
7454
7802
|
problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
|
|
7455
|
-
const change = readTweak(
|
|
7803
|
+
const change = readTweak(join42(root, ".jig", "critique", spec.slug))?.change;
|
|
7456
7804
|
if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
|
|
7457
7805
|
}
|
|
7458
7806
|
}
|
|
@@ -7461,7 +7809,7 @@ function commandProblems(root, command, surface, start) {
|
|
|
7461
7809
|
for (const file of modeFiles(root)) {
|
|
7462
7810
|
let now = "";
|
|
7463
7811
|
try {
|
|
7464
|
-
now =
|
|
7812
|
+
now = readFileSync35(join42(root, file), "utf8");
|
|
7465
7813
|
} catch {
|
|
7466
7814
|
continue;
|
|
7467
7815
|
}
|
|
@@ -7472,8 +7820,8 @@ function commandProblems(root, command, surface, start) {
|
|
|
7472
7820
|
}
|
|
7473
7821
|
}
|
|
7474
7822
|
if (command === "critique") {
|
|
7475
|
-
const dir =
|
|
7476
|
-
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"))) : [];
|
|
7477
7825
|
for (const surface2 of surfaces) {
|
|
7478
7826
|
const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
|
|
7479
7827
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -7484,7 +7832,7 @@ function commandProblems(root, command, surface, start) {
|
|
|
7484
7832
|
}
|
|
7485
7833
|
}
|
|
7486
7834
|
const own = specFor(root, surface)?.slug;
|
|
7487
|
-
if (own && surfaces.includes(own)) problems.push(...reportOmissions(
|
|
7835
|
+
if (own && surfaces.includes(own)) problems.push(...reportOmissions(join42(dir, own), own));
|
|
7488
7836
|
if (surfaces.length === 0) {
|
|
7489
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.");
|
|
7490
7838
|
}
|
|
@@ -7497,13 +7845,22 @@ function drawingBeforeAsking(root, surface) {
|
|
|
7497
7845
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7498
7846
|
const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7499
7847
|
const at = named || `.jig/mockups/${spec.slug}.html`;
|
|
7500
|
-
if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !
|
|
7848
|
+
if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync26(join42(root, at))) return [];
|
|
7501
7849
|
const found = mockupDrawingProblems(root, spec.body, at);
|
|
7502
7850
|
return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
|
|
7503
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
|
+
}
|
|
7504
7861
|
function modeFiles(root) {
|
|
7505
7862
|
try {
|
|
7506
|
-
const state = JSON.parse(
|
|
7863
|
+
const state = JSON.parse(readFileSync35(join42(root, ".jig", "state.json"), "utf8"));
|
|
7507
7864
|
return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
|
|
7508
7865
|
} catch {
|
|
7509
7866
|
return [];
|
|
@@ -7512,7 +7869,7 @@ function modeFiles(root) {
|
|
|
7512
7869
|
function reportOmissions(dir, surface) {
|
|
7513
7870
|
let report2;
|
|
7514
7871
|
try {
|
|
7515
|
-
report2 =
|
|
7872
|
+
report2 = readFileSync35(join42(dir, "REPORT.md"), "utf8").toLowerCase();
|
|
7516
7873
|
} catch {
|
|
7517
7874
|
return [];
|
|
7518
7875
|
}
|
|
@@ -7520,7 +7877,7 @@ function reportOmissions(dir, surface) {
|
|
|
7520
7877
|
for (const f of VERDICT_FILES) {
|
|
7521
7878
|
let file;
|
|
7522
7879
|
try {
|
|
7523
|
-
file = JSON.parse(
|
|
7880
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7524
7881
|
} catch {
|
|
7525
7882
|
continue;
|
|
7526
7883
|
}
|
|
@@ -7533,7 +7890,7 @@ function reportOmissions(dir, surface) {
|
|
|
7533
7890
|
if (!missing.length) return [];
|
|
7534
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).`];
|
|
7535
7892
|
}
|
|
7536
|
-
function tweakProblems(root, spec) {
|
|
7893
|
+
function tweakProblems(root, spec, owner = "") {
|
|
7537
7894
|
const problems = [];
|
|
7538
7895
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7539
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\`.`);
|
|
@@ -7541,13 +7898,21 @@ function tweakProblems(root, spec) {
|
|
|
7541
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\`.`);
|
|
7542
7899
|
else problems.push(...structureSinceApproval(root, spec, front));
|
|
7543
7900
|
const surface = spec.slug;
|
|
7544
|
-
const record = readTweak(
|
|
7901
|
+
const record = readTweak(join42(root, ".jig", "critique", surface));
|
|
7545
7902
|
if (!record) {
|
|
7546
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\`).`);
|
|
7547
7904
|
} else {
|
|
7548
7905
|
if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
|
|
7549
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.`);
|
|
7550
|
-
|
|
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);
|
|
7551
7916
|
if (unjudged.length) {
|
|
7552
7917
|
problems.push(
|
|
7553
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.`
|
|
@@ -7583,9 +7948,9 @@ function structureSinceApproval(root, spec, front) {
|
|
|
7583
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\`.`);
|
|
7584
7949
|
}
|
|
7585
7950
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7586
|
-
if (at && !/^https?:/i.test(at) &&
|
|
7951
|
+
if (at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
|
|
7587
7952
|
try {
|
|
7588
|
-
if (git(["show", `${baseline}:./${at}`]) !==
|
|
7953
|
+
if (git(["show", `${baseline}:./${at}`]) !== readFileSync35(join42(root, at), "utf8")) {
|
|
7589
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\`.`);
|
|
7590
7955
|
}
|
|
7591
7956
|
} catch {
|
|
@@ -7595,8 +7960,9 @@ function structureSinceApproval(root, spec, front) {
|
|
|
7595
7960
|
}
|
|
7596
7961
|
function readTweak(dir) {
|
|
7597
7962
|
try {
|
|
7598
|
-
const raw = JSON.parse(
|
|
7599
|
-
|
|
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 } : {} };
|
|
7600
7966
|
} catch {
|
|
7601
7967
|
return void 0;
|
|
7602
7968
|
}
|
|
@@ -7607,20 +7973,37 @@ function surfacePage(projectRoot, surface) {
|
|
|
7607
7973
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7608
7974
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
7609
7975
|
for (const candidate of candidates) {
|
|
7610
|
-
if (candidate &&
|
|
7976
|
+
if (candidate && existsSync26(join42(projectRoot, candidate))) return candidate;
|
|
7611
7977
|
}
|
|
7612
7978
|
return void 0;
|
|
7613
7979
|
}
|
|
7614
7980
|
function gate(opts) {
|
|
7615
7981
|
const root = opts.projectRoot;
|
|
7616
|
-
if (!
|
|
7982
|
+
if (!existsSync26(join42(root, "jig.config.json")) && !existsSync26(join42(root, ".jig"))) {
|
|
7617
7983
|
return { block: false, reason: "" };
|
|
7618
7984
|
}
|
|
7619
7985
|
const invocation = lastJigInvocation(opts.input.transcript_path);
|
|
7620
7986
|
const command = invocation?.command;
|
|
7621
7987
|
const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
|
|
7622
|
-
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}`) : [];
|
|
7623
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
|
+
}
|
|
7624
8007
|
const selection = selectFiles(root, false);
|
|
7625
8008
|
const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
|
|
7626
8009
|
if (changedUi) {
|
|
@@ -7642,11 +8025,11 @@ function gate(opts) {
|
|
|
7642
8025
|
}
|
|
7643
8026
|
}
|
|
7644
8027
|
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
|
|
7645
|
-
const critiqueDir =
|
|
7646
|
-
if (
|
|
8028
|
+
const critiqueDir = join42(root, ".jig", "critique");
|
|
8029
|
+
if (existsSync26(critiqueDir)) {
|
|
7647
8030
|
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
|
|
7648
|
-
const dir =
|
|
7649
|
-
if (!
|
|
8031
|
+
const dir = join42(critiqueDir, surface);
|
|
8032
|
+
if (!existsSync26(join42(dir, "screen.json")) && !existsSync26(join42(dir, "code.json"))) continue;
|
|
7650
8033
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
7651
8034
|
if (!v.ok) {
|
|
7652
8035
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -7668,11 +8051,11 @@ ${problems.join("\n\n")}`
|
|
|
7668
8051
|
};
|
|
7669
8052
|
}
|
|
7670
8053
|
const session = opts.input.session_id ?? "unknown";
|
|
7671
|
-
const key = `${session}:${
|
|
7672
|
-
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");
|
|
7673
8056
|
let state = {};
|
|
7674
8057
|
try {
|
|
7675
|
-
state = JSON.parse(
|
|
8058
|
+
state = JSON.parse(readFileSync35(stateFile, "utf8"));
|
|
7676
8059
|
} catch {
|
|
7677
8060
|
}
|
|
7678
8061
|
if (problems.length === 0) {
|
|
@@ -7707,7 +8090,7 @@ function verdictDigests(dir) {
|
|
|
7707
8090
|
for (const f of VERDICT_FILES) {
|
|
7708
8091
|
let file;
|
|
7709
8092
|
try {
|
|
7710
|
-
file = JSON.parse(
|
|
8093
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7711
8094
|
} catch {
|
|
7712
8095
|
continue;
|
|
7713
8096
|
}
|
|
@@ -7718,9 +8101,16 @@ function verdictDigests(dir) {
|
|
|
7718
8101
|
}
|
|
7719
8102
|
return out;
|
|
7720
8103
|
}
|
|
7721
|
-
function writeLock(lockPath, dir, now) {
|
|
8104
|
+
function writeLock(root, surface, lockPath, dir, now) {
|
|
8105
|
+
const pageFile2 = recordedPage(root, surface);
|
|
8106
|
+
let page;
|
|
8107
|
+
try {
|
|
8108
|
+
if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync35(join42(root, pageFile2), "utf8")) };
|
|
8109
|
+
} catch {
|
|
8110
|
+
}
|
|
8111
|
+
const tweakAt = readTweak(dir)?.at;
|
|
7722
8112
|
try {
|
|
7723
|
-
writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir) }) + "\n", "utf8");
|
|
8113
|
+
writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir), ...page ? { page } : {}, ...tweakAt ? { tweak: tweakAt } : {} }) + "\n", "utf8");
|
|
7724
8114
|
} catch {
|
|
7725
8115
|
}
|
|
7726
8116
|
}
|
|
@@ -7728,7 +8118,7 @@ function verdictChecksum(dir) {
|
|
|
7728
8118
|
const parts = VERDICT_FILES.map((f) => {
|
|
7729
8119
|
try {
|
|
7730
8120
|
return `${f}
|
|
7731
|
-
${
|
|
8121
|
+
${readFileSync35(join42(dir, f), "utf8")}`;
|
|
7732
8122
|
} catch {
|
|
7733
8123
|
return "";
|
|
7734
8124
|
}
|
|
@@ -7736,23 +8126,23 @@ ${readFileSync32(join39(dir, f), "utf8")}`;
|
|
|
7736
8126
|
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
7737
8127
|
}
|
|
7738
8128
|
function verdictGuard(root, command, inPlay) {
|
|
7739
|
-
const critiqueDir =
|
|
7740
|
-
if (!
|
|
8129
|
+
const critiqueDir = join42(root, ".jig", "critique");
|
|
8130
|
+
if (!existsSync26(critiqueDir)) return [];
|
|
7741
8131
|
const problems = [];
|
|
7742
|
-
for (const surface of
|
|
8132
|
+
for (const surface of readdirSync16(critiqueDir)) {
|
|
7743
8133
|
if (surface.startsWith("_")) continue;
|
|
7744
|
-
const dir =
|
|
8134
|
+
const dir = join42(critiqueDir, surface);
|
|
7745
8135
|
const now = verdictChecksum(dir);
|
|
7746
8136
|
if (!now) continue;
|
|
7747
|
-
const lockPath =
|
|
7748
|
-
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
7749
|
-
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);
|
|
7750
8140
|
problems.push(...lockLeftBehind(root, surface));
|
|
7751
8141
|
continue;
|
|
7752
8142
|
}
|
|
7753
8143
|
let lock = {};
|
|
7754
8144
|
try {
|
|
7755
|
-
lock = JSON.parse(
|
|
8145
|
+
lock = JSON.parse(readFileSync35(lockPath, "utf8"));
|
|
7756
8146
|
} catch {
|
|
7757
8147
|
continue;
|
|
7758
8148
|
}
|
|
@@ -7761,7 +8151,7 @@ function verdictGuard(root, command, inPlay) {
|
|
|
7761
8151
|
const own = tweakVerdictProblems(dir, surface, lock.verdicts);
|
|
7762
8152
|
problems.push(...own);
|
|
7763
8153
|
if (own.length === 0) {
|
|
7764
|
-
writeLock(lockPath, dir, now);
|
|
8154
|
+
writeLock(root, surface, lockPath, dir, now);
|
|
7765
8155
|
problems.push(...lockLeftBehind(root, surface));
|
|
7766
8156
|
}
|
|
7767
8157
|
continue;
|
|
@@ -7809,7 +8199,7 @@ function unjudgedTweakIds(dir, record) {
|
|
|
7809
8199
|
for (const f of VERDICT_FILES) {
|
|
7810
8200
|
let file;
|
|
7811
8201
|
try {
|
|
7812
|
-
file = JSON.parse(
|
|
8202
|
+
file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
|
|
7813
8203
|
} catch {
|
|
7814
8204
|
continue;
|
|
7815
8205
|
}
|
|
@@ -7821,112 +8211,15 @@ function unjudgedTweakIds(dir, record) {
|
|
|
7821
8211
|
}
|
|
7822
8212
|
function save(file, state) {
|
|
7823
8213
|
try {
|
|
7824
|
-
mkdirSync8(
|
|
8214
|
+
mkdirSync8(join42(file, ".."), { recursive: true });
|
|
7825
8215
|
writeFileSync6(file, JSON.stringify(state), "utf8");
|
|
7826
8216
|
} catch {
|
|
7827
8217
|
}
|
|
7828
8218
|
}
|
|
7829
8219
|
|
|
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
8220
|
// src/index.ts
|
|
7928
8221
|
var packageRoot = getPackageRoot();
|
|
7929
|
-
var { version } = JSON.parse(
|
|
8222
|
+
var { version } = JSON.parse(readFileSync36(join43(packageRoot, "package.json"), "utf8"));
|
|
7930
8223
|
function warnIfUnpublishedPin() {
|
|
7931
8224
|
if (isDevVersion(version)) {
|
|
7932
8225
|
console.warn(
|
|
@@ -8107,6 +8400,17 @@ program.command("seo").description("Audit what a search engine and a link previe
|
|
|
8107
8400
|
process.exit(1);
|
|
8108
8401
|
}
|
|
8109
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
|
+
});
|
|
8110
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) => {
|
|
8111
8415
|
if (opts.run) {
|
|
8112
8416
|
if (!opts.save) {
|
|
@@ -8149,7 +8453,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
8149
8453
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
8150
8454
|
let input = {};
|
|
8151
8455
|
try {
|
|
8152
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
8456
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync36(0, "utf8") || "{}");
|
|
8153
8457
|
} catch {
|
|
8154
8458
|
}
|
|
8155
8459
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -8189,7 +8493,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
8189
8493
|
});
|
|
8190
8494
|
function resolveSurfaceMode(projectRoot) {
|
|
8191
8495
|
try {
|
|
8192
|
-
const config = JSON.parse(
|
|
8496
|
+
const config = JSON.parse(readFileSync36(join43(projectRoot, "jig.config.json"), "utf8"));
|
|
8193
8497
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
8194
8498
|
return modes.length === 1 ? modes[0] : void 0;
|
|
8195
8499
|
} catch {
|