jig-ui 0.23.0 → 0.25.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 +203 -0
- package/README.md +112 -62
- package/dist/index.js +1131 -688
- package/examples/J-164.html +7 -0
- package/package.json +4 -3
- package/rules/00-anti-patterns.md +8 -2
- package/rules/01-modes.md +2 -2
- package/rules/03-patterns.md +14 -4
- package/rules/05-copy.md +1 -1
- package/rules.index.json +7 -0
- package/templates/COMMAND.md.tmpl +145 -55
- package/templates/SKILL.md.tmpl +13 -1
- package/templates/command-metadata.json +5 -0
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { readFileSync as
|
|
6
|
-
import { homedir as
|
|
7
|
-
import { join as
|
|
5
|
+
import { existsSync as existsSync28, readFileSync as readFileSync37 } from "fs";
|
|
6
|
+
import { homedir as homedir3 } from "os";
|
|
7
|
+
import { join as join44 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -55,8 +55,8 @@ function cliInvocation(version2) {
|
|
|
55
55
|
var JIG_GATE_HOOK = /(?:\bjig-ui@[^\s"]+|^jig) gate\b/;
|
|
56
56
|
|
|
57
57
|
// src/commands/install.ts
|
|
58
|
-
import { existsSync as
|
|
59
|
-
import { join as
|
|
58
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "fs";
|
|
59
|
+
import { join as join8 } from "path";
|
|
60
60
|
|
|
61
61
|
// src/adapters/types.ts
|
|
62
62
|
var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), make a small change to a built page (tweak), check the UI against the rules, or refresh the install.";
|
|
@@ -88,6 +88,236 @@ function agentsPointer(skillPath) {
|
|
|
88
88
|
].join("\n");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// src/install/agent-files.ts
|
|
92
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
93
|
+
import { homedir } from "os";
|
|
94
|
+
import { join as join4 } from "path";
|
|
95
|
+
|
|
96
|
+
// src/text.ts
|
|
97
|
+
import { readFileSync } from "fs";
|
|
98
|
+
function lf(text) {
|
|
99
|
+
return text.replace(/\r\n/g, "\n");
|
|
100
|
+
}
|
|
101
|
+
function readText(path) {
|
|
102
|
+
return lf(readFileSync(path, "utf8"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/install/atomic.ts
|
|
106
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
|
|
107
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
108
|
+
function writeFileAtomic(path, content) {
|
|
109
|
+
const dir = dirname2(path);
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
const tmp = join2(dir, `.${process.pid}-${Date.now()}.tmp`);
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(tmp, content, "utf8");
|
|
114
|
+
renameWithRetry(tmp, path);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
try {
|
|
117
|
+
unlinkSync(tmp);
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function renameWithRetry(from, to) {
|
|
124
|
+
const retryable = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
125
|
+
for (let waited = 0, delay = 10; ; waited += delay, delay = Math.min(delay * 2, 200)) {
|
|
126
|
+
try {
|
|
127
|
+
renameSync(from, to);
|
|
128
|
+
return;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const code = err.code ?? "";
|
|
131
|
+
if (process.platform !== "win32" || !retryable.has(code) || waited > 2e3) throw err;
|
|
132
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/install/vendor.ts
|
|
138
|
+
import { posix } from "path";
|
|
139
|
+
var { join: join3 } = posix;
|
|
140
|
+
var DELIMITERS = {
|
|
141
|
+
html: ["<!--", "-->"],
|
|
142
|
+
css: ["/*", "*/"]
|
|
143
|
+
};
|
|
144
|
+
function licencePathFor(relPath) {
|
|
145
|
+
const depth = relPath.split("/").length - 1;
|
|
146
|
+
return depth === 0 ? "." : Array(depth).fill("..").join("/");
|
|
147
|
+
}
|
|
148
|
+
function vendorHeader(file, version2, style = "html", licencePath = ".") {
|
|
149
|
+
const [open, close] = DELIMITERS[style];
|
|
150
|
+
const attribution = licencePath === null ? " Licensed Apache-2.0. LICENSE and NOTICE ship beside the jig skill." : ` Licensed Apache-2.0. See ${join3(licencePath, "LICENSE")} and ${join3(licencePath, "NOTICE")}.`;
|
|
151
|
+
return [
|
|
152
|
+
`${open} ${file} \u2014 vendored from Jig v${version2}.`,
|
|
153
|
+
attribution,
|
|
154
|
+
` Edit freely: \`jig update\` will not overwrite a file you have changed. ${close}`,
|
|
155
|
+
"",
|
|
156
|
+
""
|
|
157
|
+
].join("\n");
|
|
158
|
+
}
|
|
159
|
+
function findMarkerRegions(existing) {
|
|
160
|
+
const regions = [];
|
|
161
|
+
let searchFrom = 0;
|
|
162
|
+
let openStart = null;
|
|
163
|
+
while (true) {
|
|
164
|
+
const nextStart = existing.indexOf(BLOCK_START, searchFrom);
|
|
165
|
+
const nextEnd = existing.indexOf(BLOCK_END, searchFrom);
|
|
166
|
+
if (nextStart === -1 && nextEnd === -1) break;
|
|
167
|
+
const endIsNext = nextEnd !== -1 && (nextStart === -1 || nextEnd < nextStart);
|
|
168
|
+
if (endIsNext) {
|
|
169
|
+
if (openStart !== null) {
|
|
170
|
+
regions.push({ start: openStart, end: nextEnd + BLOCK_END.length });
|
|
171
|
+
openStart = null;
|
|
172
|
+
} else {
|
|
173
|
+
regions.push({ start: nextEnd, end: nextEnd + BLOCK_END.length });
|
|
174
|
+
}
|
|
175
|
+
searchFrom = nextEnd + BLOCK_END.length;
|
|
176
|
+
} else {
|
|
177
|
+
if (openStart !== null) {
|
|
178
|
+
regions.push({ start: openStart, end: openStart + BLOCK_START.length });
|
|
179
|
+
}
|
|
180
|
+
openStart = nextStart;
|
|
181
|
+
searchFrom = nextStart + BLOCK_START.length;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (openStart !== null) {
|
|
185
|
+
regions.push({ start: openStart, end: existing.length });
|
|
186
|
+
}
|
|
187
|
+
regions.sort((a, b) => a.start - b.start);
|
|
188
|
+
return regions;
|
|
189
|
+
}
|
|
190
|
+
function stripMarkerRegions(existing) {
|
|
191
|
+
const regions = findMarkerRegions(existing);
|
|
192
|
+
if (regions.length === 0) {
|
|
193
|
+
return { text: existing, insertAt: existing.length, removed: false };
|
|
194
|
+
}
|
|
195
|
+
let text = "";
|
|
196
|
+
let cursor = 0;
|
|
197
|
+
let insertAt = -1;
|
|
198
|
+
regions.forEach((region, i) => {
|
|
199
|
+
let chunk = existing.slice(cursor, region.start);
|
|
200
|
+
if (i > 0) chunk = chunk.replace(/^\n/, "");
|
|
201
|
+
text += chunk;
|
|
202
|
+
if (insertAt === -1) insertAt = text.length;
|
|
203
|
+
cursor = region.end;
|
|
204
|
+
});
|
|
205
|
+
const tail = existing.slice(cursor).replace(/^\n/, "");
|
|
206
|
+
text += tail;
|
|
207
|
+
return { text, insertAt, removed: true };
|
|
208
|
+
}
|
|
209
|
+
function upsertBlock(existing, block) {
|
|
210
|
+
const { text, insertAt, removed } = stripMarkerRegions(existing);
|
|
211
|
+
if (!removed) {
|
|
212
|
+
const separator = text.length && !text.endsWith("\n") ? "\n\n" : text.length ? "\n" : "";
|
|
213
|
+
return `${text}${separator}${block}`;
|
|
214
|
+
}
|
|
215
|
+
return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/install/agent-files.ts
|
|
219
|
+
var AGENTS_FILE = "AGENTS.md";
|
|
220
|
+
var CLAUDE_FILE = "CLAUDE.md";
|
|
221
|
+
function agentsGuide() {
|
|
222
|
+
return [
|
|
223
|
+
BLOCK_START,
|
|
224
|
+
"",
|
|
225
|
+
"# Jig: the design system for this project's UI",
|
|
226
|
+
"",
|
|
227
|
+
"Before building or reviewing any interface, load the Jig skill and follow it:",
|
|
228
|
+
"`.agents/skills/jig/SKILL.md`, or `skills/jig/SKILL.md` in your agent's own",
|
|
229
|
+
"folder (`.claude/skills/jig/SKILL.md` for Claude Code). It says which rule",
|
|
230
|
+
"files to load for the task.",
|
|
231
|
+
"",
|
|
232
|
+
"## How work goes",
|
|
233
|
+
"",
|
|
234
|
+
"- `/jig decide`, once: the project's design decisions, from the owner.",
|
|
235
|
+
"- For each page or feature: `/jig spec`, then `/jig mockup` (or skip it), then",
|
|
236
|
+
" `/jig make`, then `/jig critique` (now, or left for `/jig ship`).",
|
|
237
|
+
"- A small change to a built page: `/jig tweak`.",
|
|
238
|
+
"- Before a release: `/jig ship`.",
|
|
239
|
+
"- After any change to UI files: `jig check`.",
|
|
240
|
+
"",
|
|
241
|
+
'With no slash commands, ask in plain words: "run jig spec for the pricing page".',
|
|
242
|
+
"",
|
|
243
|
+
"## The owner's word",
|
|
244
|
+
"",
|
|
245
|
+
"A spec confirmed, a drawing approved or skipped, a critique left for later, a",
|
|
246
|
+
"decision's reason: each is recorded in the owner's own words. Never infer one",
|
|
247
|
+
"from something else they said; ask.",
|
|
248
|
+
"",
|
|
249
|
+
"## Jig's files",
|
|
250
|
+
"",
|
|
251
|
+
"- `jig.config.json`: the mode each route uses, files `check` skips (`exempt`),",
|
|
252
|
+
" and when pages are critiqued.",
|
|
253
|
+
"- The token folder: `brand.*.css` is the project's identity, edit it freely;",
|
|
254
|
+
" `mode.*.css` is Jig's, refreshed by `jig update`; `theme.css` imports both.",
|
|
255
|
+
"- `DECISIONS.md`, beside the tokens: the project's decisions, written by",
|
|
256
|
+
" `/jig decide` and `/jig tweak` only.",
|
|
257
|
+
"- `.jig/specs/`: what each page must be; `<name>.checked.json` is its check.",
|
|
258
|
+
"- `.jig/mockups/`: approved drawings.",
|
|
259
|
+
"- `.jig/critique/<page>/`: a critique's verdicts, its report and its lock.",
|
|
260
|
+
"- `.jig/state.json`: what Jig wrote, so `jig update` leaves your edits alone.",
|
|
261
|
+
"",
|
|
262
|
+
"Jig keeps this block current. Write your own instructions above or below it.",
|
|
263
|
+
"",
|
|
264
|
+
BLOCK_END,
|
|
265
|
+
""
|
|
266
|
+
].join("\n");
|
|
267
|
+
}
|
|
268
|
+
function claudeImport() {
|
|
269
|
+
return [
|
|
270
|
+
BLOCK_START,
|
|
271
|
+
"",
|
|
272
|
+
"Jig's instructions for this project are in AGENTS.md, imported here:",
|
|
273
|
+
"",
|
|
274
|
+
`@${AGENTS_FILE}`,
|
|
275
|
+
"",
|
|
276
|
+
BLOCK_END,
|
|
277
|
+
""
|
|
278
|
+
].join("\n");
|
|
279
|
+
}
|
|
280
|
+
function claudeInstalled(projectRoot, homeDir = homedir()) {
|
|
281
|
+
return existsSync2(join4(projectRoot, ".claude", "skills", "jig", "SKILL.md")) || existsSync2(join4(homeDir, ".claude", "skills", "jig", "SKILL.md"));
|
|
282
|
+
}
|
|
283
|
+
function blockIn(text) {
|
|
284
|
+
const t = lf(text);
|
|
285
|
+
const start = t.indexOf(BLOCK_START);
|
|
286
|
+
const end = t.indexOf(BLOCK_END, start);
|
|
287
|
+
return start >= 0 && end > start ? t.slice(start, end + BLOCK_END.length) : void 0;
|
|
288
|
+
}
|
|
289
|
+
var expected = (block) => block.trim();
|
|
290
|
+
function ensureAgentFiles(projectRoot, opts) {
|
|
291
|
+
const wanted = [[AGENTS_FILE, agentsGuide()]];
|
|
292
|
+
if (opts.claude) wanted.push([CLAUDE_FILE, claudeImport()]);
|
|
293
|
+
const written = [];
|
|
294
|
+
for (const [file, block] of wanted) {
|
|
295
|
+
const path = join4(projectRoot, file);
|
|
296
|
+
const before = existsSync2(path) ? readFileSync2(path, "utf8") : "";
|
|
297
|
+
if (blockIn(before) === expected(block)) continue;
|
|
298
|
+
writeFileAtomic(path, upsertBlock(before, block));
|
|
299
|
+
written.push(file);
|
|
300
|
+
}
|
|
301
|
+
return written;
|
|
302
|
+
}
|
|
303
|
+
function agentFileProblems(projectRoot, homeDir = homedir()) {
|
|
304
|
+
if (!existsSync2(join4(projectRoot, ".jig", "state.json"))) return [];
|
|
305
|
+
const wanted = [[AGENTS_FILE, agentsGuide()]];
|
|
306
|
+
if (claudeInstalled(projectRoot, homeDir)) wanted.push([CLAUDE_FILE, claudeImport()]);
|
|
307
|
+
const problems = [];
|
|
308
|
+
for (const [file, block] of wanted) {
|
|
309
|
+
const path = join4(projectRoot, file);
|
|
310
|
+
if (!existsSync2(path)) {
|
|
311
|
+
problems.push(`${file} is missing, so an agent starts without Jig's instructions.`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const found = blockIn(readFileSync2(path, "utf8"));
|
|
315
|
+
if (!found) problems.push(`${file} has no Jig block, so an agent starts without Jig's instructions.`);
|
|
316
|
+
else if (found !== expected(block)) problems.push(`${file}: Jig's block has been edited or is out of date.`);
|
|
317
|
+
}
|
|
318
|
+
return problems.map((p) => `${p} Run \`jig update\` to put it back; your own text in the file stays as it is.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
91
321
|
// src/adapters/codex.ts
|
|
92
322
|
var codex = {
|
|
93
323
|
name: "codex",
|
|
@@ -96,7 +326,7 @@ var codex = {
|
|
|
96
326
|
const skillPath = ".agents/skills/jig/SKILL.md";
|
|
97
327
|
const files = [{ relPath: skillPath, content: ctx.skillBody }];
|
|
98
328
|
const agentsPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
|
|
99
|
-
files.push({ relPath: agentsPath, content: agentsPointer(skillPath) });
|
|
329
|
+
files.push({ relPath: agentsPath, content: ctx.scope === "global" ? agentsPointer(skillPath) : agentsGuide() });
|
|
100
330
|
if (ctx.commandBody && ctx.scope === "global") {
|
|
101
331
|
files.push({ relPath: ".codex/prompts/jig.md", content: `${ctx.commandBody}
|
|
102
332
|
` });
|
|
@@ -258,22 +488,22 @@ function skillFilesFor(adapter, ctx) {
|
|
|
258
488
|
}
|
|
259
489
|
|
|
260
490
|
// src/install/references.ts
|
|
261
|
-
import { existsSync as
|
|
262
|
-
import { join as
|
|
491
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync } from "fs";
|
|
492
|
+
import { join as join5, posix as posix2 } from "path";
|
|
263
493
|
function referenceFiles(packageRoot2) {
|
|
264
|
-
const root =
|
|
265
|
-
if (!
|
|
494
|
+
const root = join5(packageRoot2, "references");
|
|
495
|
+
if (!existsSync3(root)) return [];
|
|
266
496
|
const found = [];
|
|
267
497
|
const walk2 = (dir, prefix) => {
|
|
268
498
|
for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
|
|
269
499
|
(a, b) => a.name.localeCompare(b.name)
|
|
270
500
|
)) {
|
|
271
|
-
const child =
|
|
272
|
-
const rel = prefix ?
|
|
501
|
+
const child = join5(dir, entry.name);
|
|
502
|
+
const rel = prefix ? posix2.join(prefix, entry.name) : entry.name;
|
|
273
503
|
if (entry.isDirectory()) {
|
|
274
504
|
walk2(child, rel);
|
|
275
505
|
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
276
|
-
found.push({ relPath: rel, content:
|
|
506
|
+
found.push({ relPath: rel, content: readFileSync3(child, "utf8") });
|
|
277
507
|
}
|
|
278
508
|
}
|
|
279
509
|
};
|
|
@@ -283,32 +513,11 @@ function referenceFiles(packageRoot2) {
|
|
|
283
513
|
|
|
284
514
|
// src/install/manifest.ts
|
|
285
515
|
import { createHash } from "crypto";
|
|
286
|
-
import { closeSync, existsSync as
|
|
287
|
-
import { dirname as dirname3, join as
|
|
288
|
-
|
|
289
|
-
// src/install/atomic.ts
|
|
290
|
-
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
|
|
291
|
-
import { dirname as dirname2, join as join3 } from "path";
|
|
292
|
-
function writeFileAtomic(path, content) {
|
|
293
|
-
const dir = dirname2(path);
|
|
294
|
-
mkdirSync(dir, { recursive: true });
|
|
295
|
-
const tmp = join3(dir, `.${process.pid}-${Date.now()}.tmp`);
|
|
296
|
-
try {
|
|
297
|
-
writeFileSync(tmp, content, "utf8");
|
|
298
|
-
renameSync(tmp, path);
|
|
299
|
-
} catch (err) {
|
|
300
|
-
try {
|
|
301
|
-
unlinkSync(tmp);
|
|
302
|
-
} catch {
|
|
303
|
-
}
|
|
304
|
-
throw err;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// src/install/manifest.ts
|
|
516
|
+
import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, statSync, unlinkSync as unlinkSync2, writeSync } from "fs";
|
|
517
|
+
import { dirname as dirname3, join as join6 } from "path";
|
|
309
518
|
var REGENERATE_HINT = "Re-run 'jig install --agent <name>' to fix it.";
|
|
310
519
|
function manifestPath(root, manifestDir) {
|
|
311
|
-
return
|
|
520
|
+
return join6(root, ...manifestDir.split("/"), "manifest.json");
|
|
312
521
|
}
|
|
313
522
|
function checksum(content) {
|
|
314
523
|
const normalized = content.replace(/\r\n/g, "\n");
|
|
@@ -346,10 +555,10 @@ function validateManifest(raw, path) {
|
|
|
346
555
|
}
|
|
347
556
|
function readManifest(root, manifestDir = ".jig") {
|
|
348
557
|
const path = manifestPath(root, manifestDir);
|
|
349
|
-
if (!
|
|
558
|
+
if (!existsSync4(path)) return null;
|
|
350
559
|
let raw;
|
|
351
560
|
try {
|
|
352
|
-
raw = JSON.parse(
|
|
561
|
+
raw = JSON.parse(readFileSync4(path, "utf8"));
|
|
353
562
|
} catch {
|
|
354
563
|
throw new Error(`Jig's manifest at ${path} is not valid JSON. ${REGENERATE_HINT}`);
|
|
355
564
|
}
|
|
@@ -379,12 +588,9 @@ function withLock(lock, fn) {
|
|
|
379
588
|
}
|
|
380
589
|
break;
|
|
381
590
|
} catch (err) {
|
|
382
|
-
if (err.code
|
|
591
|
+
if (!BUSY.has(err.code ?? "")) throw err;
|
|
383
592
|
if (lockIsStale(lock) || Date.now() > deadline) {
|
|
384
|
-
|
|
385
|
-
unlinkSync2(lock);
|
|
386
|
-
} catch {
|
|
387
|
-
}
|
|
593
|
+
unlinkRetrying(lock);
|
|
388
594
|
continue;
|
|
389
595
|
}
|
|
390
596
|
sleep(10 + Math.random() * 20);
|
|
@@ -393,15 +599,25 @@ function withLock(lock, fn) {
|
|
|
393
599
|
try {
|
|
394
600
|
fn();
|
|
395
601
|
} finally {
|
|
602
|
+
unlinkRetrying(lock);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
var BUSY = /* @__PURE__ */ new Set(["EEXIST", "EPERM", "EACCES", "EBUSY"]);
|
|
606
|
+
function unlinkRetrying(lock) {
|
|
607
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
396
608
|
try {
|
|
397
609
|
unlinkSync2(lock);
|
|
398
|
-
|
|
610
|
+
return;
|
|
611
|
+
} catch (err) {
|
|
612
|
+
const code = err.code ?? "";
|
|
613
|
+
if (code === "ENOENT" || !BUSY.has(code)) return;
|
|
614
|
+
sleep(5 + attempt * 5);
|
|
399
615
|
}
|
|
400
616
|
}
|
|
401
617
|
}
|
|
402
618
|
function lockIsStale(lock) {
|
|
403
619
|
try {
|
|
404
|
-
const [pid, at] =
|
|
620
|
+
const [pid, at] = readFileSync4(lock, "utf8").trim().split(/\s+/).map(Number);
|
|
405
621
|
if (at && Date.now() - at > LOCK_STALE_MS) return true;
|
|
406
622
|
if (pid && pid !== process.pid) {
|
|
407
623
|
try {
|
|
@@ -421,7 +637,7 @@ function sleep(ms) {
|
|
|
421
637
|
}
|
|
422
638
|
function readManifestQuietly(path) {
|
|
423
639
|
try {
|
|
424
|
-
const parsed = JSON.parse(
|
|
640
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
425
641
|
if (!isPlainObject(parsed) || !isPlainObject(parsed.files)) return null;
|
|
426
642
|
return parsed;
|
|
427
643
|
} catch {
|
|
@@ -431,9 +647,9 @@ function readManifestQuietly(path) {
|
|
|
431
647
|
function isModified(projectRoot, relPath, m) {
|
|
432
648
|
const recorded = m.files[relPath];
|
|
433
649
|
if (!recorded) return false;
|
|
434
|
-
const abs =
|
|
435
|
-
if (!
|
|
436
|
-
return checksum(
|
|
650
|
+
const abs = join6(projectRoot, relPath);
|
|
651
|
+
if (!existsSync4(abs)) return false;
|
|
652
|
+
return checksum(readFileSync4(abs, "utf8")) !== recorded;
|
|
437
653
|
}
|
|
438
654
|
|
|
439
655
|
// src/template/render.ts
|
|
@@ -460,97 +676,16 @@ function renderCommandTable(metadata2) {
|
|
|
460
676
|
return ["| Command | Description | Status |", "| --- | --- | --- |", ...rows].join("\n");
|
|
461
677
|
}
|
|
462
678
|
|
|
463
|
-
// src/install/vendor.ts
|
|
464
|
-
import { posix as posix2 } from "path";
|
|
465
|
-
var { join: join5 } = posix2;
|
|
466
|
-
var DELIMITERS = {
|
|
467
|
-
html: ["<!--", "-->"],
|
|
468
|
-
css: ["/*", "*/"]
|
|
469
|
-
};
|
|
470
|
-
function licencePathFor(relPath) {
|
|
471
|
-
const depth = relPath.split("/").length - 1;
|
|
472
|
-
return depth === 0 ? "." : Array(depth).fill("..").join("/");
|
|
473
|
-
}
|
|
474
|
-
function vendorHeader(file, version2, style = "html", licencePath = ".") {
|
|
475
|
-
const [open, close] = DELIMITERS[style];
|
|
476
|
-
const attribution = licencePath === null ? " Licensed Apache-2.0. LICENSE and NOTICE ship beside the jig skill." : ` Licensed Apache-2.0. See ${join5(licencePath, "LICENSE")} and ${join5(licencePath, "NOTICE")}.`;
|
|
477
|
-
return [
|
|
478
|
-
`${open} ${file} \u2014 vendored from Jig v${version2}.`,
|
|
479
|
-
attribution,
|
|
480
|
-
` Edit freely: \`jig update\` will not overwrite a file you have changed. ${close}`,
|
|
481
|
-
"",
|
|
482
|
-
""
|
|
483
|
-
].join("\n");
|
|
484
|
-
}
|
|
485
|
-
function findMarkerRegions(existing) {
|
|
486
|
-
const regions = [];
|
|
487
|
-
let searchFrom = 0;
|
|
488
|
-
let openStart = null;
|
|
489
|
-
while (true) {
|
|
490
|
-
const nextStart = existing.indexOf(BLOCK_START, searchFrom);
|
|
491
|
-
const nextEnd = existing.indexOf(BLOCK_END, searchFrom);
|
|
492
|
-
if (nextStart === -1 && nextEnd === -1) break;
|
|
493
|
-
const endIsNext = nextEnd !== -1 && (nextStart === -1 || nextEnd < nextStart);
|
|
494
|
-
if (endIsNext) {
|
|
495
|
-
if (openStart !== null) {
|
|
496
|
-
regions.push({ start: openStart, end: nextEnd + BLOCK_END.length });
|
|
497
|
-
openStart = null;
|
|
498
|
-
} else {
|
|
499
|
-
regions.push({ start: nextEnd, end: nextEnd + BLOCK_END.length });
|
|
500
|
-
}
|
|
501
|
-
searchFrom = nextEnd + BLOCK_END.length;
|
|
502
|
-
} else {
|
|
503
|
-
if (openStart !== null) {
|
|
504
|
-
regions.push({ start: openStart, end: openStart + BLOCK_START.length });
|
|
505
|
-
}
|
|
506
|
-
openStart = nextStart;
|
|
507
|
-
searchFrom = nextStart + BLOCK_START.length;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
if (openStart !== null) {
|
|
511
|
-
regions.push({ start: openStart, end: existing.length });
|
|
512
|
-
}
|
|
513
|
-
regions.sort((a, b) => a.start - b.start);
|
|
514
|
-
return regions;
|
|
515
|
-
}
|
|
516
|
-
function stripMarkerRegions(existing) {
|
|
517
|
-
const regions = findMarkerRegions(existing);
|
|
518
|
-
if (regions.length === 0) {
|
|
519
|
-
return { text: existing, insertAt: existing.length, removed: false };
|
|
520
|
-
}
|
|
521
|
-
let text = "";
|
|
522
|
-
let cursor = 0;
|
|
523
|
-
let insertAt = -1;
|
|
524
|
-
regions.forEach((region, i) => {
|
|
525
|
-
let chunk = existing.slice(cursor, region.start);
|
|
526
|
-
if (i > 0) chunk = chunk.replace(/^\n/, "");
|
|
527
|
-
text += chunk;
|
|
528
|
-
if (insertAt === -1) insertAt = text.length;
|
|
529
|
-
cursor = region.end;
|
|
530
|
-
});
|
|
531
|
-
const tail = existing.slice(cursor).replace(/^\n/, "");
|
|
532
|
-
text += tail;
|
|
533
|
-
return { text, insertAt, removed: true };
|
|
534
|
-
}
|
|
535
|
-
function upsertBlock(existing, block) {
|
|
536
|
-
const { text, insertAt, removed } = stripMarkerRegions(existing);
|
|
537
|
-
if (!removed) {
|
|
538
|
-
const separator = text.length && !text.endsWith("\n") ? "\n\n" : text.length ? "\n" : "";
|
|
539
|
-
return `${text}${separator}${block}`;
|
|
540
|
-
}
|
|
541
|
-
return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
679
|
// src/install/writer.ts
|
|
545
|
-
import { existsSync as
|
|
546
|
-
import { dirname as dirname4, join as
|
|
680
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
|
|
681
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
547
682
|
|
|
548
683
|
// src/install/line-endings.ts
|
|
549
684
|
function dominantEnding(existing) {
|
|
550
685
|
const crlf = (existing.match(/\r\n/g) ?? []).length;
|
|
551
|
-
const
|
|
552
|
-
if (crlf === 0 &&
|
|
553
|
-
return crlf >=
|
|
686
|
+
const lf5 = (existing.match(/(?<!\r)\n/g) ?? []).length;
|
|
687
|
+
if (crlf === 0 && lf5 === 0) return "\n";
|
|
688
|
+
return crlf >= lf5 ? "\r\n" : "\n";
|
|
554
689
|
}
|
|
555
690
|
function matchLineEndings(content, existing) {
|
|
556
691
|
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
@@ -567,9 +702,9 @@ function createWriter(root, seed = {}) {
|
|
|
567
702
|
return {
|
|
568
703
|
files,
|
|
569
704
|
write(key, content) {
|
|
570
|
-
const abs =
|
|
705
|
+
const abs = join7(root, ...key.split("/"));
|
|
571
706
|
mkdirSync3(dirname4(abs), { recursive: true });
|
|
572
|
-
const existing =
|
|
707
|
+
const existing = existsSync5(abs) ? readFileSync5(abs, "utf8") : void 0;
|
|
573
708
|
const toWrite = matchLineEndings(content, existing);
|
|
574
709
|
writeFileAtomic(abs, toWrite);
|
|
575
710
|
files[key] = checksum(toWrite);
|
|
@@ -579,15 +714,15 @@ function createWriter(root, seed = {}) {
|
|
|
579
714
|
}
|
|
580
715
|
function bundleFiles(packageRoot2, dir) {
|
|
581
716
|
const ext = dir === "rules" ? ".md" : ".css";
|
|
582
|
-
return readdirSync2(
|
|
717
|
+
return readdirSync2(join7(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
|
|
583
718
|
}
|
|
584
719
|
|
|
585
720
|
// src/commands/install.ts
|
|
586
721
|
var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
|
|
587
722
|
function buildSkillBody(packageRoot2, rulesPath, version2) {
|
|
588
|
-
const template =
|
|
723
|
+
const template = readFileSync6(join8(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
|
|
589
724
|
const metadata2 = JSON.parse(
|
|
590
|
-
|
|
725
|
+
readFileSync6(join8(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
591
726
|
);
|
|
592
727
|
return render(template, {
|
|
593
728
|
command_prefix: "/jig ",
|
|
@@ -604,12 +739,12 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
|
|
|
604
739
|
}
|
|
605
740
|
function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
|
|
606
741
|
const metadata2 = JSON.parse(
|
|
607
|
-
|
|
742
|
+
readFileSync6(join8(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
608
743
|
);
|
|
609
744
|
const subcommands = Object.entries(metadata2).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
|
|
610
|
-
const templatePath =
|
|
611
|
-
if (!
|
|
612
|
-
const template =
|
|
745
|
+
const templatePath = join8(packageRoot2, "templates", "COMMAND.md.tmpl");
|
|
746
|
+
if (!existsSync6(templatePath)) return { body: "", subcommands };
|
|
747
|
+
const template = readFileSync6(templatePath, "utf8");
|
|
613
748
|
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");
|
|
614
749
|
const unresolved = [...new Set(body.match(/\{\{[a-z_]+\}\}/g) ?? [])];
|
|
615
750
|
if (unresolved.length > 0) {
|
|
@@ -637,6 +772,7 @@ function install(opts) {
|
|
|
637
772
|
written: [],
|
|
638
773
|
skipped: [],
|
|
639
774
|
stopHook: installStopHook(opts.projectRoot, opts.version),
|
|
775
|
+
agentFiles: ensureAgentFiles(opts.projectRoot, { claude: true }),
|
|
640
776
|
hookOnly: `Jig's skill is installed globally for 'claude' (${globalReferenceDir} under your home directory), so it stays there. Added only this project's Stop hook.`
|
|
641
777
|
};
|
|
642
778
|
}
|
|
@@ -657,9 +793,9 @@ function install(opts) {
|
|
|
657
793
|
existingManifest = null;
|
|
658
794
|
}
|
|
659
795
|
const planned = [];
|
|
660
|
-
const rulesDir =
|
|
796
|
+
const rulesDir = join8(opts.packageRoot, "rules");
|
|
661
797
|
for (const file of bundleFiles(opts.packageRoot, "rules")) {
|
|
662
|
-
const body =
|
|
798
|
+
const body = readFileSync6(join8(rulesDir, file), "utf8");
|
|
663
799
|
planned.push({
|
|
664
800
|
key: relKey(referenceDir, "rules", file),
|
|
665
801
|
content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
|
|
@@ -675,13 +811,13 @@ function install(opts) {
|
|
|
675
811
|
}
|
|
676
812
|
planned.push({
|
|
677
813
|
key: relKey(referenceDir, "rules.index.json"),
|
|
678
|
-
content:
|
|
814
|
+
content: readFileSync6(join8(opts.packageRoot, "rules.index.json"), "utf8"),
|
|
679
815
|
checkable: true
|
|
680
816
|
});
|
|
681
817
|
for (const src of ["LICENSE", "NOTICE"]) {
|
|
682
818
|
planned.push({
|
|
683
819
|
key: relKey(referenceDir, src),
|
|
684
|
-
content:
|
|
820
|
+
content: readFileSync6(join8(opts.packageRoot, src), "utf8"),
|
|
685
821
|
checkable: false
|
|
686
822
|
});
|
|
687
823
|
}
|
|
@@ -703,9 +839,9 @@ function install(opts) {
|
|
|
703
839
|
});
|
|
704
840
|
for (const file of skillFiles) {
|
|
705
841
|
const key = file.relPath;
|
|
706
|
-
const abs =
|
|
842
|
+
const abs = join8(installRoot, ...key.split("/"));
|
|
707
843
|
const isBlockFile = file.content.includes(BLOCK_START);
|
|
708
|
-
const content = isBlockFile &&
|
|
844
|
+
const content = isBlockFile && existsSync6(abs) ? upsertBlock(readFileSync6(abs, "utf8"), file.content) : file.content;
|
|
709
845
|
planned.push({ key, content, checkable: !isBlockFile });
|
|
710
846
|
}
|
|
711
847
|
const writer = createWriter(installRoot);
|
|
@@ -732,11 +868,12 @@ function install(opts) {
|
|
|
732
868
|
writeManifest(installRoot, manifest, referenceDir);
|
|
733
869
|
written.push(relKey(referenceDir, "manifest.json"));
|
|
734
870
|
const stopHook = opts.hook && opts.agent === "claude" && opts.scope === "project" ? installStopHook(installRoot, opts.version) : void 0;
|
|
735
|
-
|
|
871
|
+
const agentFiles = opts.scope === "project" ? ensureAgentFiles(opts.projectRoot, { claude: opts.agent === "claude" || claudeInstalled(opts.projectRoot, opts.homeDir) }) : [];
|
|
872
|
+
return { written, skipped, stopHook, agentFiles };
|
|
736
873
|
}
|
|
737
874
|
function hasStopHook(projectRoot) {
|
|
738
875
|
try {
|
|
739
|
-
const settings =
|
|
876
|
+
const settings = readFileSync6(join8(projectRoot, ".claude", "settings.json"), "utf8");
|
|
740
877
|
const hooks = JSON.parse(settings).hooks?.Stop ?? [];
|
|
741
878
|
return hooks.some((group) => (group.hooks ?? []).some((h) => JIG_GATE_HOOK.test(h.command ?? "")));
|
|
742
879
|
} catch {
|
|
@@ -744,11 +881,11 @@ function hasStopHook(projectRoot) {
|
|
|
744
881
|
}
|
|
745
882
|
}
|
|
746
883
|
function installStopHook(projectRoot, version2) {
|
|
747
|
-
const path =
|
|
884
|
+
const path = join8(projectRoot, ".claude", "settings.json");
|
|
748
885
|
let settings = {};
|
|
749
|
-
if (
|
|
886
|
+
if (existsSync6(path)) {
|
|
750
887
|
try {
|
|
751
|
-
settings = JSON.parse(
|
|
888
|
+
settings = JSON.parse(readFileSync6(path, "utf8"));
|
|
752
889
|
} catch {
|
|
753
890
|
return false;
|
|
754
891
|
}
|
|
@@ -759,14 +896,14 @@ function installStopHook(projectRoot, version2) {
|
|
|
759
896
|
const stop = (hooks.Stop ?? []).map((group) => ({ ...group, hooks: group.hooks.filter((h) => !isJig(h)) })).filter((group) => group.hooks.length > 0);
|
|
760
897
|
stop.push({ hooks: [{ type: "command", command }] });
|
|
761
898
|
settings.hooks = { ...hooks, Stop: stop };
|
|
762
|
-
mkdirSync4(
|
|
899
|
+
mkdirSync4(join8(projectRoot, ".claude"), { recursive: true });
|
|
763
900
|
writeFileSync2(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
764
901
|
return true;
|
|
765
902
|
}
|
|
766
903
|
|
|
767
904
|
// src/commands/update.ts
|
|
768
|
-
import { join as
|
|
769
|
-
import { existsSync as
|
|
905
|
+
import { join as join11 } from "path";
|
|
906
|
+
import { existsSync as existsSync9, readFileSync as readFileSync9 } from "fs";
|
|
770
907
|
|
|
771
908
|
// src/install/target.ts
|
|
772
909
|
import { resolve } from "path";
|
|
@@ -809,14 +946,14 @@ function resolveAllInstalled(projectRoot, homeDir) {
|
|
|
809
946
|
}
|
|
810
947
|
|
|
811
948
|
// src/init/state.ts
|
|
812
|
-
import { existsSync as
|
|
813
|
-
import { dirname as dirname5, join as
|
|
814
|
-
var INIT_MANIFEST_REL =
|
|
949
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync7 } from "fs";
|
|
950
|
+
import { dirname as dirname5, join as join9 } from "path";
|
|
951
|
+
var INIT_MANIFEST_REL = join9(".jig", "state.json");
|
|
815
952
|
function readInitManifest(projectRoot) {
|
|
816
|
-
const path =
|
|
817
|
-
if (!
|
|
953
|
+
const path = join9(projectRoot, INIT_MANIFEST_REL);
|
|
954
|
+
if (!existsSync7(path)) return null;
|
|
818
955
|
try {
|
|
819
|
-
const raw = JSON.parse(
|
|
956
|
+
const raw = JSON.parse(readFileSync7(path, "utf8"));
|
|
820
957
|
if (typeof raw !== "object" || raw === null) return null;
|
|
821
958
|
const { files, version: version2, modes } = raw;
|
|
822
959
|
if (typeof files !== "object" || files === null) return null;
|
|
@@ -831,7 +968,7 @@ function readInitManifest(projectRoot) {
|
|
|
831
968
|
}
|
|
832
969
|
var MERGE_ATTEMPTS = 5;
|
|
833
970
|
function writeInitManifest(projectRoot, m) {
|
|
834
|
-
const path =
|
|
971
|
+
const path = join9(projectRoot, INIT_MANIFEST_REL);
|
|
835
972
|
mkdirSync5(dirname5(path), { recursive: true });
|
|
836
973
|
for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
|
|
837
974
|
const onDisk = readInitManifest(projectRoot);
|
|
@@ -852,15 +989,15 @@ function isInitFileModified(projectRoot, relPath, m) {
|
|
|
852
989
|
}
|
|
853
990
|
|
|
854
991
|
// src/init/migrate.ts
|
|
855
|
-
import { existsSync as
|
|
856
|
-
import { join as
|
|
992
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8, readdirSync as readdirSync3, rmSync } from "fs";
|
|
993
|
+
import { join as join10 } from "path";
|
|
857
994
|
var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
|
|
858
995
|
function isLegacyInstallArtifact(name) {
|
|
859
996
|
return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
|
|
860
997
|
}
|
|
861
998
|
function readLegacyManifest(dirAbs) {
|
|
862
999
|
try {
|
|
863
|
-
const raw = JSON.parse(
|
|
1000
|
+
const raw = JSON.parse(readFileSync8(join10(dirAbs, "manifest.json"), "utf8"));
|
|
864
1001
|
if (raw && typeof raw === "object" && typeof raw.files === "object") {
|
|
865
1002
|
return raw;
|
|
866
1003
|
}
|
|
@@ -872,8 +1009,8 @@ function classify(projectRoot, relPath, legacyManifest) {
|
|
|
872
1009
|
return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
|
|
873
1010
|
}
|
|
874
1011
|
function detectLegacyRules(projectRoot) {
|
|
875
|
-
const jigDir =
|
|
876
|
-
if (!
|
|
1012
|
+
const jigDir = join10(projectRoot, ".jig");
|
|
1013
|
+
if (!existsSync8(jigDir)) return { present: false, files: [] };
|
|
877
1014
|
const legacyManifest = readLegacyManifest(jigDir);
|
|
878
1015
|
let entries;
|
|
879
1016
|
try {
|
|
@@ -889,12 +1026,12 @@ function detectLegacyRules(projectRoot) {
|
|
|
889
1026
|
}
|
|
890
1027
|
function detectLegacyCursorRules(projectRoot) {
|
|
891
1028
|
const bundleDirRel = ".cursor/rules/jig";
|
|
892
|
-
const bundleDir =
|
|
1029
|
+
const bundleDir = join10(projectRoot, ...bundleDirRel.split("/"));
|
|
893
1030
|
const mdcRel = ".cursor/rules/jig.mdc";
|
|
894
|
-
const mdcAbs =
|
|
1031
|
+
const mdcAbs = join10(projectRoot, ...mdcRel.split("/"));
|
|
895
1032
|
const legacyManifest = readLegacyManifest(bundleDir);
|
|
896
1033
|
const files = [];
|
|
897
|
-
if (
|
|
1034
|
+
if (existsSync8(mdcAbs)) {
|
|
898
1035
|
files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
|
|
899
1036
|
}
|
|
900
1037
|
let entries;
|
|
@@ -948,7 +1085,7 @@ function removableLegacyFiles(report2) {
|
|
|
948
1085
|
function removeLegacyFiles(projectRoot, relPaths) {
|
|
949
1086
|
for (const relPath of relPaths) {
|
|
950
1087
|
try {
|
|
951
|
-
rmSync(
|
|
1088
|
+
rmSync(join10(projectRoot, ...relPath.split("/")), { force: true });
|
|
952
1089
|
} catch {
|
|
953
1090
|
}
|
|
954
1091
|
}
|
|
@@ -991,6 +1128,9 @@ function update(opts) {
|
|
|
991
1128
|
const initResult = updateInitFiles(opts);
|
|
992
1129
|
updated.push(...initResult.updated);
|
|
993
1130
|
skipped.push(...initResult.skipped);
|
|
1131
|
+
if (existsSync9(join11(opts.projectRoot, ".jig", "state.json"))) {
|
|
1132
|
+
updated.push(...ensureAgentFiles(opts.projectRoot, { claude: claudeInstalled(opts.projectRoot, opts.homeDir) }));
|
|
1133
|
+
}
|
|
994
1134
|
return {
|
|
995
1135
|
updated,
|
|
996
1136
|
skipped,
|
|
@@ -1013,14 +1153,14 @@ function updateTarget(opts, resolved) {
|
|
|
1013
1153
|
const write = (key, content) => {
|
|
1014
1154
|
updated.push(writer.write(key, content));
|
|
1015
1155
|
};
|
|
1016
|
-
const rulesDir =
|
|
1156
|
+
const rulesDir = join11(opts.packageRoot, "rules");
|
|
1017
1157
|
for (const file of bundleFiles(opts.packageRoot, "rules")) {
|
|
1018
1158
|
const key = relKey(referenceDir, "rules", file);
|
|
1019
1159
|
if (isModified(installRoot, key, existing)) {
|
|
1020
1160
|
skipped.push(key);
|
|
1021
1161
|
continue;
|
|
1022
1162
|
}
|
|
1023
|
-
write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) +
|
|
1163
|
+
write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync9(join11(rulesDir, file), "utf8"));
|
|
1024
1164
|
}
|
|
1025
1165
|
for (const ref of referenceFiles(opts.packageRoot)) {
|
|
1026
1166
|
const key = relKey(referenceDir, ...ref.relPath.split("/"));
|
|
@@ -1034,7 +1174,7 @@ function updateTarget(opts, resolved) {
|
|
|
1034
1174
|
if (isModified(installRoot, indexKey, existing)) {
|
|
1035
1175
|
skipped.push(indexKey);
|
|
1036
1176
|
} else {
|
|
1037
|
-
write(indexKey,
|
|
1177
|
+
write(indexKey, readFileSync9(join11(opts.packageRoot, "rules.index.json"), "utf8"));
|
|
1038
1178
|
}
|
|
1039
1179
|
const rulesPath = rulesPathFor(referenceDir, discoveredScope);
|
|
1040
1180
|
const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
|
|
@@ -1056,8 +1196,8 @@ function updateTarget(opts, resolved) {
|
|
|
1056
1196
|
const key = file.relPath;
|
|
1057
1197
|
const isBlockFile = file.content.includes(BLOCK_START);
|
|
1058
1198
|
if (isBlockFile) {
|
|
1059
|
-
const abs =
|
|
1060
|
-
const current =
|
|
1199
|
+
const abs = join11(installRoot, ...key.split("/"));
|
|
1200
|
+
const current = existsSync9(abs) ? readFileSync9(abs, "utf8") : "";
|
|
1061
1201
|
write(key, upsertBlock(current, file.content));
|
|
1062
1202
|
continue;
|
|
1063
1203
|
}
|
|
@@ -1068,7 +1208,7 @@ function updateTarget(opts, resolved) {
|
|
|
1068
1208
|
write(key, file.content);
|
|
1069
1209
|
}
|
|
1070
1210
|
for (const src of ["LICENSE", "NOTICE"]) {
|
|
1071
|
-
write(relKey(referenceDir, src),
|
|
1211
|
+
write(relKey(referenceDir, src), readFileSync9(join11(opts.packageRoot, src), "utf8"));
|
|
1072
1212
|
}
|
|
1073
1213
|
const manifest = {
|
|
1074
1214
|
...existing,
|
|
@@ -1084,7 +1224,7 @@ function updateInitFiles(opts) {
|
|
|
1084
1224
|
const updated = [];
|
|
1085
1225
|
const skipped = [];
|
|
1086
1226
|
const tokens = [];
|
|
1087
|
-
const tokensDir =
|
|
1227
|
+
const tokensDir = join11(opts.packageRoot, "tokens");
|
|
1088
1228
|
const initManifest = readInitManifest(opts.projectRoot);
|
|
1089
1229
|
if (initManifest) {
|
|
1090
1230
|
const initFiles = { ...initManifest.files };
|
|
@@ -1096,10 +1236,10 @@ function updateInitFiles(opts) {
|
|
|
1096
1236
|
for (const key of keys) {
|
|
1097
1237
|
let before = "";
|
|
1098
1238
|
try {
|
|
1099
|
-
before =
|
|
1239
|
+
before = readFileSync9(join11(opts.projectRoot, ...key.split("/")), "utf8");
|
|
1100
1240
|
} catch {
|
|
1101
1241
|
}
|
|
1102
|
-
const fresh =
|
|
1242
|
+
const fresh = readFileSync9(join11(tokensDir, file), "utf8");
|
|
1103
1243
|
if (isInitFileModified(opts.projectRoot, key, initManifest)) {
|
|
1104
1244
|
skipped.push(key);
|
|
1105
1245
|
const change2 = tokenChange(key, before, fresh, true);
|
|
@@ -1120,12 +1260,12 @@ function updateInitFiles(opts) {
|
|
|
1120
1260
|
}
|
|
1121
1261
|
|
|
1122
1262
|
// src/commands/explain.ts
|
|
1123
|
-
import { existsSync as
|
|
1124
|
-
import { join as
|
|
1263
|
+
import { existsSync as existsSync10 } from "fs";
|
|
1264
|
+
import { join as join16 } from "path";
|
|
1125
1265
|
|
|
1126
1266
|
// src/rules/load.ts
|
|
1127
|
-
import { readFileSync as
|
|
1128
|
-
import { join as
|
|
1267
|
+
import { readFileSync as readFileSync10, readdirSync as readdirSync4 } from "fs";
|
|
1268
|
+
import { join as join12 } from "path";
|
|
1129
1269
|
|
|
1130
1270
|
// src/rules/parse.ts
|
|
1131
1271
|
var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
|
|
@@ -1221,9 +1361,9 @@ function loadRules(rulesDir, indexPath) {
|
|
|
1221
1361
|
const files = readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort();
|
|
1222
1362
|
const parsed = [];
|
|
1223
1363
|
for (const file of files) {
|
|
1224
|
-
parsed.push(...parseRules(
|
|
1364
|
+
parsed.push(...parseRules(readFileSync10(join12(rulesDir, file), "utf8"), file));
|
|
1225
1365
|
}
|
|
1226
|
-
const entries = validateIndex(JSON.parse(
|
|
1366
|
+
const entries = validateIndex(JSON.parse(readFileSync10(indexPath, "utf8")));
|
|
1227
1367
|
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
1228
1368
|
const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
|
|
1229
1369
|
if (missing.length) {
|
|
@@ -1245,8 +1385,8 @@ function loadRules(rulesDir, indexPath) {
|
|
|
1245
1385
|
}
|
|
1246
1386
|
|
|
1247
1387
|
// src/rules/specs.ts
|
|
1248
|
-
import { readFileSync as
|
|
1249
|
-
import { join as
|
|
1388
|
+
import { readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
|
|
1389
|
+
import { join as join13 } from "path";
|
|
1250
1390
|
var KINDS = {
|
|
1251
1391
|
P: "pattern",
|
|
1252
1392
|
M: "mode",
|
|
@@ -1305,14 +1445,14 @@ function parseSpecs(markdown, sourceFile) {
|
|
|
1305
1445
|
function loadSpecs(rulesDir) {
|
|
1306
1446
|
const specs = [];
|
|
1307
1447
|
for (const file of readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
1308
|
-
specs.push(...parseSpecs(
|
|
1448
|
+
specs.push(...parseSpecs(readFileSync11(join13(rulesDir, file), "utf8"), file));
|
|
1309
1449
|
}
|
|
1310
1450
|
return specs;
|
|
1311
1451
|
}
|
|
1312
1452
|
|
|
1313
1453
|
// src/rules/sections.ts
|
|
1314
|
-
import { readFileSync as
|
|
1315
|
-
import { join as
|
|
1454
|
+
import { readFileSync as readFileSync12, readdirSync as readdirSync6 } from "fs";
|
|
1455
|
+
import { join as join14 } from "path";
|
|
1316
1456
|
var RULE_HEADING = /^###\s+[A-Z]-\d+\b/;
|
|
1317
1457
|
var SPEC_HEADING = /^##\s+[PMLRT]-\d+\b/;
|
|
1318
1458
|
var ANY_HEADING = /^(#{1,6})\s+(.*?)\s*$/;
|
|
@@ -1353,16 +1493,16 @@ function parseSections(markdown, file) {
|
|
|
1353
1493
|
function loadSections(rulesDir) {
|
|
1354
1494
|
const out = [];
|
|
1355
1495
|
for (const file of readdirSync6(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
1356
|
-
out.push(...parseSections(
|
|
1496
|
+
out.push(...parseSections(readFileSync12(join14(rulesDir, file), "utf8"), file));
|
|
1357
1497
|
}
|
|
1358
1498
|
return out;
|
|
1359
1499
|
}
|
|
1360
1500
|
|
|
1361
1501
|
// src/rules/layers.ts
|
|
1362
|
-
import { readFileSync as
|
|
1363
|
-
import { join as
|
|
1502
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
1503
|
+
import { join as join15 } from "path";
|
|
1364
1504
|
function loadLayers(packageRoot2) {
|
|
1365
|
-
return JSON.parse(
|
|
1505
|
+
return JSON.parse(readFileSync13(join15(packageRoot2, "layers.json"), "utf8"));
|
|
1366
1506
|
}
|
|
1367
1507
|
|
|
1368
1508
|
// src/commands/explain.ts
|
|
@@ -1444,8 +1584,8 @@ function renderSection(s) {
|
|
|
1444
1584
|
function explain(opts) {
|
|
1445
1585
|
const raw = opts.ruleId.trim().toUpperCase();
|
|
1446
1586
|
const root = opts.packageRoot ?? assetRoot();
|
|
1447
|
-
const rulesDir =
|
|
1448
|
-
const rules = loadRules(rulesDir,
|
|
1587
|
+
const rulesDir = join16(root, "rules");
|
|
1588
|
+
const rules = loadRules(rulesDir, join16(root, "rules.index.json"));
|
|
1449
1589
|
const specs = loadSpecs(rulesDir);
|
|
1450
1590
|
const sections = loadSections(rulesDir);
|
|
1451
1591
|
const entries = searchEntries(rules, specs, sections);
|
|
@@ -1501,8 +1641,8 @@ function explain(opts) {
|
|
|
1501
1641
|
const id = `${shape[1]}-${shape[2]}`;
|
|
1502
1642
|
const rule = rules.find((r) => r.id === id);
|
|
1503
1643
|
if (rule) {
|
|
1504
|
-
const example =
|
|
1505
|
-
return
|
|
1644
|
+
const example = join16(root, "examples", `${id}.html`);
|
|
1645
|
+
return existsSync10(example) ? `${renderRule(rule)}
|
|
1506
1646
|
|
|
1507
1647
|
Example: ${example} (a dont and a do, each a self-contained HTML fragment)` : renderRule(rule);
|
|
1508
1648
|
}
|
|
@@ -1559,13 +1699,13 @@ function sectionsOf(entries) {
|
|
|
1559
1699
|
}
|
|
1560
1700
|
|
|
1561
1701
|
// src/commands/check.ts
|
|
1562
|
-
import { existsSync as
|
|
1563
|
-
import { join as
|
|
1702
|
+
import { existsSync as existsSync16, readFileSync as readFileSync21 } from "fs";
|
|
1703
|
+
import { join as join25 } from "path";
|
|
1564
1704
|
|
|
1565
1705
|
// src/check/files.ts
|
|
1566
1706
|
import { execFileSync } from "child_process";
|
|
1567
1707
|
import { readdirSync as readdirSync7, realpathSync, statSync as statSync2 } from "fs";
|
|
1568
|
-
import { join as
|
|
1708
|
+
import { join as join17, relative, sep } from "path";
|
|
1569
1709
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
1570
1710
|
"node_modules",
|
|
1571
1711
|
"dist",
|
|
@@ -1640,7 +1780,7 @@ function walk(root, dir, out, seen) {
|
|
|
1640
1780
|
}
|
|
1641
1781
|
for (const entry of entries) {
|
|
1642
1782
|
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
1643
|
-
const abs =
|
|
1783
|
+
const abs = join17(dir, entry.name);
|
|
1644
1784
|
let isDir = entry.isDirectory();
|
|
1645
1785
|
let isFile = entry.isFile();
|
|
1646
1786
|
if (entry.isSymbolicLink()) {
|
|
@@ -1782,6 +1922,10 @@ function formatReport(findings, meta) {
|
|
|
1782
1922
|
` Scope: files changed since HEAD, not the whole project \u2014 this is the inner-loop default. A clean result here means nothing in your diff fired, not that the project is clean. Run 'jig check --all' for that.`
|
|
1783
1923
|
);
|
|
1784
1924
|
}
|
|
1925
|
+
for (const problem of meta.agentFiles ?? []) lines.push(` ${problem}`);
|
|
1926
|
+
for (const { entry, why } of meta.exemptIgnored ?? []) {
|
|
1927
|
+
lines.push(` jig.config.json exempt: "${entry}" is ignored: ${why}`);
|
|
1928
|
+
}
|
|
1785
1929
|
if (meta.exemptPatterns && meta.exemptPatterns.length > 0) {
|
|
1786
1930
|
const n = meta.exempt?.length ?? 0;
|
|
1787
1931
|
lines.push(` ${n} file(s) exempt via jig.config.json and not scanned:`);
|
|
@@ -1789,6 +1933,9 @@ function formatReport(findings, meta) {
|
|
|
1789
1933
|
const note = count === 0 ? meta.scope === "changed" ? "matches nothing among the changed files \u2014 not necessarily wrong" : "matches nothing \u2014 check the path" : tooBroad ? `${count} files \u2014 likely too broad, review it` : `${count} file${count > 1 ? "s" : ""}`;
|
|
1790
1934
|
lines.push(` ${pattern} (${note})`);
|
|
1791
1935
|
}
|
|
1936
|
+
for (const { pattern, suggest } of meta.exemptPatterns) {
|
|
1937
|
+
if (suggest?.length) lines.push(` ${pattern}: did you mean ${suggest.map((f) => `"${f}"`).join(" or ")}? Paths start at the project folder.`);
|
|
1938
|
+
}
|
|
1792
1939
|
if (n > 0) {
|
|
1793
1940
|
lines.push(` ${meta.exempt.slice(0, 8).join(", ")}${n > 8 ? `, and ${n - 8} more` : ""}`);
|
|
1794
1941
|
}
|
|
@@ -1951,9 +2098,142 @@ function isReaderText(file) {
|
|
|
1951
2098
|
return hasExtension(file, STYLE_HOST_EXTENSIONS);
|
|
1952
2099
|
}
|
|
1953
2100
|
|
|
2101
|
+
// src/check/length.ts
|
|
2102
|
+
var LENGTH_UNITS = [
|
|
2103
|
+
"px",
|
|
2104
|
+
"rem",
|
|
2105
|
+
"em",
|
|
2106
|
+
"pt",
|
|
2107
|
+
"vh",
|
|
2108
|
+
"vw",
|
|
2109
|
+
"vmin",
|
|
2110
|
+
"vmax",
|
|
2111
|
+
"ch",
|
|
2112
|
+
"ex"
|
|
2113
|
+
];
|
|
2114
|
+
var UNITS = LENGTH_UNITS.join("|");
|
|
2115
|
+
var LENGTH_RE = new RegExp(`^-?\\d*\\.?\\d+(?:${UNITS})$`, "i");
|
|
2116
|
+
var LENGTH_SCAN_RE = new RegExp(`(-?\\d*\\.?\\d+)(${UNITS})`, "gi");
|
|
2117
|
+
function isExcludedLength(value, unit) {
|
|
2118
|
+
if (value === 0) return true;
|
|
2119
|
+
return unit.toLowerCase() === "px" && (Math.abs(value) === 1 || Math.abs(value) === 2);
|
|
2120
|
+
}
|
|
2121
|
+
function hasHardCodedLength(value) {
|
|
2122
|
+
LENGTH_SCAN_RE.lastIndex = 0;
|
|
2123
|
+
let m;
|
|
2124
|
+
while (m = LENGTH_SCAN_RE.exec(value)) {
|
|
2125
|
+
if (!isExcludedLength(parseFloat(m[1]), m[2])) return true;
|
|
2126
|
+
}
|
|
2127
|
+
return false;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// src/check/tailwind.ts
|
|
2131
|
+
var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
|
|
2132
|
+
function readBraced(source, open) {
|
|
2133
|
+
let depth = 0;
|
|
2134
|
+
for (let i = open; i < source.length; i++) {
|
|
2135
|
+
const ch = source[i];
|
|
2136
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
2137
|
+
const quote = ch;
|
|
2138
|
+
i++;
|
|
2139
|
+
while (i < source.length && source[i] !== quote) {
|
|
2140
|
+
if (source[i] === "\\") i++;
|
|
2141
|
+
i++;
|
|
2142
|
+
}
|
|
2143
|
+
continue;
|
|
2144
|
+
}
|
|
2145
|
+
if (ch === "{") depth++;
|
|
2146
|
+
else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
|
|
2147
|
+
}
|
|
2148
|
+
return null;
|
|
2149
|
+
}
|
|
2150
|
+
function classAttributeValues(source) {
|
|
2151
|
+
const out = [];
|
|
2152
|
+
const lineAt = (offset) => source.slice(0, offset).split("\n").length;
|
|
2153
|
+
for (const m of source.matchAll(CLASS_ATTR)) {
|
|
2154
|
+
const at = m.index + m[0].length;
|
|
2155
|
+
const opener = source[at];
|
|
2156
|
+
if (opener === '"' || opener === "'") {
|
|
2157
|
+
const end = source.indexOf(opener, at + 1);
|
|
2158
|
+
if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
if (opener !== "{") continue;
|
|
2162
|
+
const raw = readBraced(source, at);
|
|
2163
|
+
if (raw === null) continue;
|
|
2164
|
+
const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
|
|
2165
|
+
(q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
|
|
2166
|
+
);
|
|
2167
|
+
if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
|
|
2168
|
+
}
|
|
2169
|
+
return out;
|
|
2170
|
+
}
|
|
2171
|
+
var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
|
|
2172
|
+
var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
|
|
2173
|
+
var LENGTH_PARTS = /^(-?\d*\.?\d+)([a-z]+)$/i;
|
|
2174
|
+
function arbitraryValues(classes) {
|
|
2175
|
+
const out = [];
|
|
2176
|
+
for (const m of classes.matchAll(ARBITRARY)) {
|
|
2177
|
+
const [, utility, rawValue] = m;
|
|
2178
|
+
const value = rawValue.replace(/_/g, " ");
|
|
2179
|
+
if (/var\(|--/.test(value)) continue;
|
|
2180
|
+
if (COLOUR.test(value)) {
|
|
2181
|
+
out.push({ utility, value, kind: "colour" });
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
if (!LENGTH_RE.test(value)) continue;
|
|
2185
|
+
const parts = LENGTH_PARTS.exec(value);
|
|
2186
|
+
if (isExcludedLength(parseFloat(parts[1]), parts[2])) continue;
|
|
2187
|
+
out.push({ utility, value, kind: "length" });
|
|
2188
|
+
}
|
|
2189
|
+
return out;
|
|
2190
|
+
}
|
|
2191
|
+
var PALETTE = {
|
|
2192
|
+
white: "#ffffff",
|
|
2193
|
+
black: "#000000",
|
|
2194
|
+
"gray-50": "#f9fafb",
|
|
2195
|
+
"gray-100": "#f3f4f6",
|
|
2196
|
+
"gray-200": "#e5e7eb",
|
|
2197
|
+
"gray-300": "#d1d5db",
|
|
2198
|
+
"gray-400": "#9ca3af",
|
|
2199
|
+
"gray-500": "#6b7280",
|
|
2200
|
+
"gray-600": "#4b5563",
|
|
2201
|
+
"gray-700": "#374151",
|
|
2202
|
+
"gray-800": "#1f2937",
|
|
2203
|
+
"gray-900": "#111827",
|
|
2204
|
+
"gray-950": "#030712",
|
|
2205
|
+
"slate-50": "#f8fafc",
|
|
2206
|
+
"slate-400": "#94a3b8",
|
|
2207
|
+
"slate-500": "#64748b",
|
|
2208
|
+
"slate-800": "#1e293b",
|
|
2209
|
+
"slate-900": "#0f172a",
|
|
2210
|
+
"slate-950": "#020617",
|
|
2211
|
+
"zinc-400": "#a1a1aa",
|
|
2212
|
+
"zinc-500": "#71717a",
|
|
2213
|
+
"zinc-900": "#18181b",
|
|
2214
|
+
"neutral-400": "#a3a3a3",
|
|
2215
|
+
"neutral-500": "#737373",
|
|
2216
|
+
"neutral-900": "#171717",
|
|
2217
|
+
"stone-400": "#a8a29e",
|
|
2218
|
+
"stone-500": "#78716c",
|
|
2219
|
+
"stone-900": "#1c1917"
|
|
2220
|
+
};
|
|
2221
|
+
function palettePairs(classes) {
|
|
2222
|
+
let background;
|
|
2223
|
+
let foreground;
|
|
2224
|
+
for (const token of classes.split(/\s+/)) {
|
|
2225
|
+
const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
|
|
2226
|
+
const bg = /^bg-(.+)$/.exec(bare2);
|
|
2227
|
+
const fg = /^text-(.+)$/.exec(bare2);
|
|
2228
|
+
if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
|
|
2229
|
+
if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
|
|
2230
|
+
}
|
|
2231
|
+
return background && foreground ? [{ background, foreground }] : [];
|
|
2232
|
+
}
|
|
2233
|
+
|
|
1954
2234
|
// src/check/run.ts
|
|
1955
|
-
import { readFileSync as
|
|
1956
|
-
import { join as
|
|
2235
|
+
import { readFileSync as readFileSync15 } from "fs";
|
|
2236
|
+
import { join as join19 } from "path";
|
|
1957
2237
|
|
|
1958
2238
|
// src/check/css.ts
|
|
1959
2239
|
function maskComments(source) {
|
|
@@ -2757,139 +3037,6 @@ var pureBlackWhite = {
|
|
|
2757
3037
|
}
|
|
2758
3038
|
};
|
|
2759
3039
|
|
|
2760
|
-
// src/check/length.ts
|
|
2761
|
-
var LENGTH_UNITS = [
|
|
2762
|
-
"px",
|
|
2763
|
-
"rem",
|
|
2764
|
-
"em",
|
|
2765
|
-
"pt",
|
|
2766
|
-
"vh",
|
|
2767
|
-
"vw",
|
|
2768
|
-
"vmin",
|
|
2769
|
-
"vmax",
|
|
2770
|
-
"ch",
|
|
2771
|
-
"ex"
|
|
2772
|
-
];
|
|
2773
|
-
var UNITS = LENGTH_UNITS.join("|");
|
|
2774
|
-
var LENGTH_RE = new RegExp(`^-?\\d*\\.?\\d+(?:${UNITS})$`, "i");
|
|
2775
|
-
var LENGTH_SCAN_RE = new RegExp(`(-?\\d*\\.?\\d+)(${UNITS})`, "gi");
|
|
2776
|
-
function isExcludedLength(value, unit) {
|
|
2777
|
-
if (value === 0) return true;
|
|
2778
|
-
return unit.toLowerCase() === "px" && (Math.abs(value) === 1 || Math.abs(value) === 2);
|
|
2779
|
-
}
|
|
2780
|
-
function hasHardCodedLength(value) {
|
|
2781
|
-
LENGTH_SCAN_RE.lastIndex = 0;
|
|
2782
|
-
let m;
|
|
2783
|
-
while (m = LENGTH_SCAN_RE.exec(value)) {
|
|
2784
|
-
if (!isExcludedLength(parseFloat(m[1]), m[2])) return true;
|
|
2785
|
-
}
|
|
2786
|
-
return false;
|
|
2787
|
-
}
|
|
2788
|
-
|
|
2789
|
-
// src/check/tailwind.ts
|
|
2790
|
-
var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
|
|
2791
|
-
function readBraced(source, open) {
|
|
2792
|
-
let depth = 0;
|
|
2793
|
-
for (let i = open; i < source.length; i++) {
|
|
2794
|
-
const ch = source[i];
|
|
2795
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
2796
|
-
const quote = ch;
|
|
2797
|
-
i++;
|
|
2798
|
-
while (i < source.length && source[i] !== quote) {
|
|
2799
|
-
if (source[i] === "\\") i++;
|
|
2800
|
-
i++;
|
|
2801
|
-
}
|
|
2802
|
-
continue;
|
|
2803
|
-
}
|
|
2804
|
-
if (ch === "{") depth++;
|
|
2805
|
-
else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
|
|
2806
|
-
}
|
|
2807
|
-
return null;
|
|
2808
|
-
}
|
|
2809
|
-
function classAttributeValues(source) {
|
|
2810
|
-
const out = [];
|
|
2811
|
-
const lineAt = (offset) => source.slice(0, offset).split("\n").length;
|
|
2812
|
-
for (const m of source.matchAll(CLASS_ATTR)) {
|
|
2813
|
-
const at = m.index + m[0].length;
|
|
2814
|
-
const opener = source[at];
|
|
2815
|
-
if (opener === '"' || opener === "'") {
|
|
2816
|
-
const end = source.indexOf(opener, at + 1);
|
|
2817
|
-
if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
|
|
2818
|
-
continue;
|
|
2819
|
-
}
|
|
2820
|
-
if (opener !== "{") continue;
|
|
2821
|
-
const raw = readBraced(source, at);
|
|
2822
|
-
if (raw === null) continue;
|
|
2823
|
-
const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
|
|
2824
|
-
(q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
|
|
2825
|
-
);
|
|
2826
|
-
if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
|
|
2827
|
-
}
|
|
2828
|
-
return out;
|
|
2829
|
-
}
|
|
2830
|
-
var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
|
|
2831
|
-
var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
|
|
2832
|
-
var LENGTH_PARTS = /^(-?\d*\.?\d+)([a-z]+)$/i;
|
|
2833
|
-
function arbitraryValues(classes) {
|
|
2834
|
-
const out = [];
|
|
2835
|
-
for (const m of classes.matchAll(ARBITRARY)) {
|
|
2836
|
-
const [, utility, rawValue] = m;
|
|
2837
|
-
const value = rawValue.replace(/_/g, " ");
|
|
2838
|
-
if (/var\(|--/.test(value)) continue;
|
|
2839
|
-
if (COLOUR.test(value)) {
|
|
2840
|
-
out.push({ utility, value, kind: "colour" });
|
|
2841
|
-
continue;
|
|
2842
|
-
}
|
|
2843
|
-
if (!LENGTH_RE.test(value)) continue;
|
|
2844
|
-
const parts = LENGTH_PARTS.exec(value);
|
|
2845
|
-
if (isExcludedLength(parseFloat(parts[1]), parts[2])) continue;
|
|
2846
|
-
out.push({ utility, value, kind: "length" });
|
|
2847
|
-
}
|
|
2848
|
-
return out;
|
|
2849
|
-
}
|
|
2850
|
-
var PALETTE = {
|
|
2851
|
-
white: "#ffffff",
|
|
2852
|
-
black: "#000000",
|
|
2853
|
-
"gray-50": "#f9fafb",
|
|
2854
|
-
"gray-100": "#f3f4f6",
|
|
2855
|
-
"gray-200": "#e5e7eb",
|
|
2856
|
-
"gray-300": "#d1d5db",
|
|
2857
|
-
"gray-400": "#9ca3af",
|
|
2858
|
-
"gray-500": "#6b7280",
|
|
2859
|
-
"gray-600": "#4b5563",
|
|
2860
|
-
"gray-700": "#374151",
|
|
2861
|
-
"gray-800": "#1f2937",
|
|
2862
|
-
"gray-900": "#111827",
|
|
2863
|
-
"gray-950": "#030712",
|
|
2864
|
-
"slate-50": "#f8fafc",
|
|
2865
|
-
"slate-400": "#94a3b8",
|
|
2866
|
-
"slate-500": "#64748b",
|
|
2867
|
-
"slate-800": "#1e293b",
|
|
2868
|
-
"slate-900": "#0f172a",
|
|
2869
|
-
"slate-950": "#020617",
|
|
2870
|
-
"zinc-400": "#a1a1aa",
|
|
2871
|
-
"zinc-500": "#71717a",
|
|
2872
|
-
"zinc-900": "#18181b",
|
|
2873
|
-
"neutral-400": "#a3a3a3",
|
|
2874
|
-
"neutral-500": "#737373",
|
|
2875
|
-
"neutral-900": "#171717",
|
|
2876
|
-
"stone-400": "#a8a29e",
|
|
2877
|
-
"stone-500": "#78716c",
|
|
2878
|
-
"stone-900": "#1c1917"
|
|
2879
|
-
};
|
|
2880
|
-
function palettePairs(classes) {
|
|
2881
|
-
let background;
|
|
2882
|
-
let foreground;
|
|
2883
|
-
for (const token of classes.split(/\s+/)) {
|
|
2884
|
-
const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
|
|
2885
|
-
const bg = /^bg-(.+)$/.exec(bare2);
|
|
2886
|
-
const fg = /^text-(.+)$/.exec(bare2);
|
|
2887
|
-
if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
|
|
2888
|
-
if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
|
|
2889
|
-
}
|
|
2890
|
-
return background && foreground ? [{ background, foreground }] : [];
|
|
2891
|
-
}
|
|
2892
|
-
|
|
2893
3040
|
// src/check/detectors/contrast-floor.ts
|
|
2894
3041
|
var FLOOR = 4.5;
|
|
2895
3042
|
var LARGE_FLOOR = 3;
|
|
@@ -3372,8 +3519,8 @@ var menuState = {
|
|
|
3372
3519
|
};
|
|
3373
3520
|
|
|
3374
3521
|
// src/check/declared-properties.ts
|
|
3375
|
-
import { existsSync as
|
|
3376
|
-
import { join as
|
|
3522
|
+
import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
|
|
3523
|
+
import { join as join18 } from "path";
|
|
3377
3524
|
var DECLARATION_RE = /(?<![\w-])['"]?(--[A-Za-z_][\w-]*)['"]?\s*:/g;
|
|
3378
3525
|
var AT_PROPERTY_RE = /@property\s+(--[\w-]+)/g;
|
|
3379
3526
|
var SET_PROPERTY_RE = /setProperty\(\s*['"`](--[\w-]+)/g;
|
|
@@ -3385,16 +3532,16 @@ function addAll(names, text) {
|
|
|
3385
3532
|
}
|
|
3386
3533
|
}
|
|
3387
3534
|
function readPackageImport(projectRoot, spec) {
|
|
3388
|
-
const base =
|
|
3535
|
+
const base = join18(projectRoot, "node_modules", spec);
|
|
3389
3536
|
for (const candidate of [base, `${base}.css`]) {
|
|
3390
3537
|
try {
|
|
3391
|
-
if (
|
|
3538
|
+
if (existsSync11(candidate) && candidate.endsWith(".css")) return readFileSync14(candidate, "utf8");
|
|
3392
3539
|
} catch {
|
|
3393
3540
|
}
|
|
3394
3541
|
}
|
|
3395
3542
|
try {
|
|
3396
|
-
const pkg = JSON.parse(
|
|
3397
|
-
if (pkg.style) return
|
|
3543
|
+
const pkg = JSON.parse(readFileSync14(join18(base, "package.json"), "utf8"));
|
|
3544
|
+
if (pkg.style) return readFileSync14(join18(base, pkg.style), "utf8");
|
|
3398
3545
|
} catch {
|
|
3399
3546
|
}
|
|
3400
3547
|
return void 0;
|
|
@@ -3406,7 +3553,7 @@ function collectDeclaredProperties(projectRoot, files, tokenNames = []) {
|
|
|
3406
3553
|
for (const file of files) {
|
|
3407
3554
|
let raw;
|
|
3408
3555
|
try {
|
|
3409
|
-
raw =
|
|
3556
|
+
raw = readFileSync14(join18(projectRoot, file), "utf8");
|
|
3410
3557
|
} catch {
|
|
3411
3558
|
continue;
|
|
3412
3559
|
}
|
|
@@ -3484,7 +3631,7 @@ var EM_DASH = /—/;
|
|
|
3484
3631
|
var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
|
|
3485
3632
|
function maskNonProse(source) {
|
|
3486
3633
|
const blank3 = (m) => " ".repeat(m.length);
|
|
3487
|
-
return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
3634
|
+
return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<(blockquote|q|pre|code|samp|kbd)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
3488
3635
|
}
|
|
3489
3636
|
function markdownProse(source) {
|
|
3490
3637
|
const blank3 = (m) => " ".repeat(m.length);
|
|
@@ -3767,6 +3914,8 @@ function firstGroup(match) {
|
|
|
3767
3914
|
if (!value) return void 0;
|
|
3768
3915
|
return { value, index: match.index ?? 0 };
|
|
3769
3916
|
}
|
|
3917
|
+
var PREVIEW_TITLE = /property\s*=\s*["']og:title["']|(?:^|[\s{,])["']?(?:ogTitle|og:title)["']?\s*:|openGraph\s*:\s*\{[^}]*?\btitle\s*:/;
|
|
3918
|
+
var PREVIEW_DESCRIPTION = /property\s*=\s*["']og:description["']|(?:^|[\s{,])["']?(?:ogDescription|og:description)["']?\s*:|openGraph\s*:\s*\{[^}]*?\bdescription\s*:/;
|
|
3770
3919
|
function readMetadata(source) {
|
|
3771
3920
|
const htmlTitle = firstGroup(HTML_TITLE.exec(source));
|
|
3772
3921
|
const title = htmlTitle ?? firstGroup(KEYED_TITLE.exec(source));
|
|
@@ -3784,6 +3933,9 @@ function readMetadata(source) {
|
|
|
3784
3933
|
description: description && !/^\{|\$\{/.test(description.value) ? description : void 0,
|
|
3785
3934
|
hasTitle: ANY_TITLE.test(source),
|
|
3786
3935
|
hasDescription: ANY_DESCRIPTION.test(source),
|
|
3936
|
+
hasPreviewTitle: PREVIEW_TITLE.test(source),
|
|
3937
|
+
hasPreviewDescription: PREVIEW_DESCRIPTION.test(source),
|
|
3938
|
+
hasPreview: PREVIEW_TITLE.test(source) && PREVIEW_DESCRIPTION.test(source),
|
|
3787
3939
|
declares: DECLARES.test(source),
|
|
3788
3940
|
computedTitle: COMPUTED_TITLE.test(source),
|
|
3789
3941
|
noindex: NOINDEX.some((re) => re.test(source))
|
|
@@ -3796,6 +3948,7 @@ var TITLE_BUDGET = 60;
|
|
|
3796
3948
|
var DESCRIPTION_BUDGET = 155;
|
|
3797
3949
|
|
|
3798
3950
|
// src/check/detectors/metadata.ts
|
|
3951
|
+
var HEAD_FRAGMENT = /<svelte:head[\s>]|<head[\s>]|<Head[\s>]|<Helmet[\s>]|slot\s*=\s*["']head["']/;
|
|
3799
3952
|
var INVENTED_DATE = /(?:lastModified|datePublished|dateModified|publishedTime|modifiedTime)\s*:\s*(?:new Date\(\s*\)|Date\.now\(\s*\))/;
|
|
3800
3953
|
var metadata = {
|
|
3801
3954
|
name: "metadata",
|
|
@@ -3829,9 +3982,19 @@ var metadata = {
|
|
|
3829
3982
|
if (indexable && !found.hasDescription) {
|
|
3830
3983
|
at("J-121", 0, "this page has no meta description \u2014 the search engine writes one from whatever text it finds first, usually the navigation");
|
|
3831
3984
|
}
|
|
3985
|
+
if (indexable && !found.hasPreview) {
|
|
3986
|
+
const missing = [!found.hasPreviewTitle && "og:title", !found.hasPreviewDescription && "og:description"].filter(Boolean).join(" and ");
|
|
3987
|
+
at("J-164", 0, found.hasPreviewTitle || found.hasPreviewDescription ? `this page has no ${missing} \u2014 a link to it, pasted into a message or a post, shows a card with that part guessed` : "this page has no link preview (og:title and og:description) \u2014 a link to it, pasted into a message or a post, shows a card built from whatever the page happens to carry");
|
|
3988
|
+
}
|
|
3832
3989
|
if (!indexable && !found.noindex) {
|
|
3833
3990
|
at("J-123", 0, `this surface is ${ctx.mode}, which is what somebody reaches after signing in, and the page carries no noindex \u2014 robots.txt is public and advisory, and is not this`);
|
|
3834
3991
|
}
|
|
3992
|
+
} else {
|
|
3993
|
+
const own = HEAD_FRAGMENT.test(raw) ? HTML_DESCRIPTION.exec(raw) ?? HTML_DESCRIPTION_REVERSED.exec(raw) : null;
|
|
3994
|
+
const indexable = ctx.mode !== "product" && ctx.mode !== "operator";
|
|
3995
|
+
if (indexable && own && found.description && !found.hasPreviewDescription) {
|
|
3996
|
+
at("J-164", own.index, `this file writes the page's description but no og:description${found.hasPreviewTitle ? "" : " or og:title"} \u2014 a link to it, pasted into a message or a post, shows a card without the description written here`);
|
|
3997
|
+
}
|
|
3835
3998
|
}
|
|
3836
3999
|
return findings.filter((f) => f.ruleId === ctx.ruleId);
|
|
3837
4000
|
}
|
|
@@ -4256,7 +4419,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
4256
4419
|
if (applicable.length === 0) continue;
|
|
4257
4420
|
let raw;
|
|
4258
4421
|
try {
|
|
4259
|
-
raw =
|
|
4422
|
+
raw = readFileSync15(join19(projectRoot, file), "utf8");
|
|
4260
4423
|
} catch {
|
|
4261
4424
|
continue;
|
|
4262
4425
|
}
|
|
@@ -4270,8 +4433,8 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
4270
4433
|
}
|
|
4271
4434
|
|
|
4272
4435
|
// src/check/tokens.ts
|
|
4273
|
-
import { existsSync as
|
|
4274
|
-
import { join as
|
|
4436
|
+
import { existsSync as existsSync12, readdirSync as readdirSync8, readFileSync as readFileSync16 } from "fs";
|
|
4437
|
+
import { join as join20 } from "path";
|
|
4275
4438
|
function rootDeclarations(source) {
|
|
4276
4439
|
const out = {};
|
|
4277
4440
|
for (const block of splitRuleBlocks(source)) {
|
|
@@ -4286,20 +4449,20 @@ function rootDeclarations(source) {
|
|
|
4286
4449
|
}
|
|
4287
4450
|
function loadTokenMap(projectRoot, consumerFiles = []) {
|
|
4288
4451
|
const jig = {};
|
|
4289
|
-
const tokensDir =
|
|
4290
|
-
if (
|
|
4452
|
+
const tokensDir = join20(projectRoot, ".jig", "tokens");
|
|
4453
|
+
if (existsSync12(tokensDir)) {
|
|
4291
4454
|
for (const file of readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
4292
|
-
Object.assign(jig, rootDeclarations(
|
|
4455
|
+
Object.assign(jig, rootDeclarations(readFileSync16(join20(tokensDir, file), "utf8")));
|
|
4293
4456
|
}
|
|
4294
4457
|
}
|
|
4295
4458
|
const consumer = {};
|
|
4296
4459
|
const ambiguous = /* @__PURE__ */ new Set();
|
|
4297
|
-
const vendored =
|
|
4460
|
+
const vendored = join20(".jig", "tokens");
|
|
4298
4461
|
for (const file of consumerFiles) {
|
|
4299
4462
|
if (file.includes(vendored)) continue;
|
|
4300
4463
|
let source;
|
|
4301
4464
|
try {
|
|
4302
|
-
source =
|
|
4465
|
+
source = readFileSync16(join20(projectRoot, file), "utf8");
|
|
4303
4466
|
} catch {
|
|
4304
4467
|
continue;
|
|
4305
4468
|
}
|
|
@@ -4318,8 +4481,8 @@ function loadTokenMap(projectRoot, consumerFiles = []) {
|
|
|
4318
4481
|
}
|
|
4319
4482
|
|
|
4320
4483
|
// src/check/exempt.ts
|
|
4321
|
-
import { existsSync as
|
|
4322
|
-
import { join as
|
|
4484
|
+
import { existsSync as existsSync13, readFileSync as readFileSync17 } from "fs";
|
|
4485
|
+
import { join as join21 } from "path";
|
|
4323
4486
|
function globToRegExp(glob) {
|
|
4324
4487
|
let out = "";
|
|
4325
4488
|
for (let i = 0; i < glob.length; i++) {
|
|
@@ -4338,18 +4501,37 @@ function globToRegExp(glob) {
|
|
|
4338
4501
|
return new RegExp(`^${out}$`);
|
|
4339
4502
|
}
|
|
4340
4503
|
function readExemptions(projectRoot) {
|
|
4341
|
-
const
|
|
4342
|
-
|
|
4504
|
+
const none = { patterns: [], ignored: [] };
|
|
4505
|
+
const configPath = join21(projectRoot, "jig.config.json");
|
|
4506
|
+
if (!existsSync13(configPath)) return none;
|
|
4343
4507
|
let declared;
|
|
4344
4508
|
try {
|
|
4345
|
-
declared = JSON.parse(
|
|
4509
|
+
declared = JSON.parse(readFileSync17(configPath, "utf8")).exempt;
|
|
4346
4510
|
} catch {
|
|
4347
|
-
return
|
|
4511
|
+
return none;
|
|
4512
|
+
}
|
|
4513
|
+
if (!Array.isArray(declared)) return none;
|
|
4514
|
+
const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
4515
|
+
const patterns = [];
|
|
4516
|
+
const ignored = [];
|
|
4517
|
+
for (const raw of declared) {
|
|
4518
|
+
if (typeof raw !== "string" || !raw.trim()) continue;
|
|
4519
|
+
let p = raw.trim().replace(/\\/g, "/");
|
|
4520
|
+
if (p.startsWith(`${root}/`)) p = p.slice(root.length + 1);
|
|
4521
|
+
p = p.replace(/^(\.\/)+/, "");
|
|
4522
|
+
if (p.startsWith("/") || /^[A-Za-z]:\//.test(p)) {
|
|
4523
|
+
ignored.push({ entry: raw, why: "an absolute path outside this project. Write it from the project folder, where jig.config.json is: `src/\u2026`" });
|
|
4524
|
+
} else if (p.split("/").includes("..")) {
|
|
4525
|
+
ignored.push({ entry: raw, why: "it leaves the project folder (`..`). Write it from the project folder, where jig.config.json is: `src/\u2026`" });
|
|
4526
|
+
} else {
|
|
4527
|
+
patterns.push(p);
|
|
4528
|
+
}
|
|
4348
4529
|
}
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
);
|
|
4530
|
+
return { patterns, ignored };
|
|
4531
|
+
}
|
|
4532
|
+
function suggestPaths(pattern, files) {
|
|
4533
|
+
if (pattern.includes("/") || /[*?]/.test(pattern)) return [];
|
|
4534
|
+
return files.filter((f) => f === pattern || f.endsWith(`/${pattern}`)).slice(0, 3);
|
|
4353
4535
|
}
|
|
4354
4536
|
var LIKELY_TOO_BROAD = 3;
|
|
4355
4537
|
function applyExemptions(files, patterns) {
|
|
@@ -4374,8 +4556,8 @@ function applyExemptions(files, patterns) {
|
|
|
4374
4556
|
}
|
|
4375
4557
|
|
|
4376
4558
|
// src/check/waiver.ts
|
|
4377
|
-
import { readFileSync as
|
|
4378
|
-
import { join as
|
|
4559
|
+
import { readFileSync as readFileSync18 } from "fs";
|
|
4560
|
+
import { join as join22 } from "path";
|
|
4379
4561
|
var WAIVER = /jig-allow\s+([A-Z]-\d+(?:\s*,\s*[A-Z]-\d+)*)\s*:(.*)$/;
|
|
4380
4562
|
function readWaiver(line) {
|
|
4381
4563
|
const m = WAIVER.exec(line);
|
|
@@ -4390,7 +4572,7 @@ function applyWaivers(root, findings) {
|
|
|
4390
4572
|
let cached = lines.get(file);
|
|
4391
4573
|
if (!cached) {
|
|
4392
4574
|
try {
|
|
4393
|
-
cached =
|
|
4575
|
+
cached = readFileSync18(join22(root, file), "utf8").split("\n");
|
|
4394
4576
|
} catch {
|
|
4395
4577
|
cached = [];
|
|
4396
4578
|
}
|
|
@@ -4455,8 +4637,8 @@ function hasMenuToggle(raw) {
|
|
|
4455
4637
|
}
|
|
4456
4638
|
|
|
4457
4639
|
// src/check/mode-wiring.ts
|
|
4458
|
-
import { existsSync as
|
|
4459
|
-
import { dirname as dirname6, join as
|
|
4640
|
+
import { existsSync as existsSync14, readFileSync as readFileSync19 } from "fs";
|
|
4641
|
+
import { dirname as dirname6, join as join23 } from "path";
|
|
4460
4642
|
var MODES = ["editorial", "product", "operator"];
|
|
4461
4643
|
function configProblems(config) {
|
|
4462
4644
|
const problems = [];
|
|
@@ -4478,10 +4660,10 @@ function configProblems(config) {
|
|
|
4478
4660
|
}
|
|
4479
4661
|
function modeWiringProblems(projectRoot) {
|
|
4480
4662
|
let config;
|
|
4481
|
-
const path =
|
|
4482
|
-
if (!
|
|
4663
|
+
const path = join23(projectRoot, "jig.config.json");
|
|
4664
|
+
if (!existsSync14(path)) return [];
|
|
4483
4665
|
try {
|
|
4484
|
-
config = JSON.parse(
|
|
4666
|
+
config = JSON.parse(readFileSync19(path, "utf8"));
|
|
4485
4667
|
} catch {
|
|
4486
4668
|
return [{ mode: "", barrel: "jig.config.json", message: "jig.config.json is not valid JSON, so nothing in it applies" }];
|
|
4487
4669
|
}
|
|
@@ -4494,10 +4676,10 @@ function modeWiringProblems(projectRoot) {
|
|
|
4494
4676
|
const rel = (f) => dir === "." ? f : `${dir}/${f}`;
|
|
4495
4677
|
const multi = modes.length > 1;
|
|
4496
4678
|
const barrelOf = (mode) => multi ? `theme.${mode}.css` : "theme.css";
|
|
4497
|
-
const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) =>
|
|
4679
|
+
const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync14(join23(projectRoot, rel(f))));
|
|
4498
4680
|
if (!anyBarrel) return [];
|
|
4499
4681
|
const problems = [];
|
|
4500
|
-
if (multi &&
|
|
4682
|
+
if (multi && existsSync14(join23(projectRoot, rel("theme.css")))) {
|
|
4501
4683
|
problems.push({
|
|
4502
4684
|
mode: modes[0],
|
|
4503
4685
|
barrel: rel("theme.css"),
|
|
@@ -4509,7 +4691,7 @@ function modeWiringProblems(projectRoot) {
|
|
|
4509
4691
|
const modeFile = `mode.${mode}.css`;
|
|
4510
4692
|
let body;
|
|
4511
4693
|
try {
|
|
4512
|
-
body =
|
|
4694
|
+
body = readFileSync19(join23(projectRoot, barrel), "utf8");
|
|
4513
4695
|
} catch {
|
|
4514
4696
|
problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${barrel} does not exist` });
|
|
4515
4697
|
return;
|
|
@@ -4521,7 +4703,7 @@ function modeWiringProblems(projectRoot) {
|
|
|
4521
4703
|
barrel,
|
|
4522
4704
|
message: `jig.config.json declares ${mode}, but ${barrel} imports ${imported ? `mode.${imported}.css` : "no mode file"} \u2014 the page is styled at ${imported ?? "no"} density`
|
|
4523
4705
|
});
|
|
4524
|
-
} else if (!
|
|
4706
|
+
} else if (!existsSync14(join23(projectRoot, rel(modeFile)))) {
|
|
4525
4707
|
problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${rel(modeFile)} does not exist` });
|
|
4526
4708
|
}
|
|
4527
4709
|
});
|
|
@@ -4529,8 +4711,8 @@ function modeWiringProblems(projectRoot) {
|
|
|
4529
4711
|
}
|
|
4530
4712
|
|
|
4531
4713
|
// src/check/token-audit.ts
|
|
4532
|
-
import { existsSync as
|
|
4533
|
-
import { join as
|
|
4714
|
+
import { existsSync as existsSync15, readdirSync as readdirSync9, readFileSync as readFileSync20 } from "fs";
|
|
4715
|
+
import { join as join24 } from "path";
|
|
4534
4716
|
var TEXT_FLOOR = 4.5;
|
|
4535
4717
|
var UI_FLOOR = 3;
|
|
4536
4718
|
var FOREGROUNDS = [
|
|
@@ -4586,24 +4768,24 @@ function floorFor(token) {
|
|
|
4586
4768
|
function tokenFiles(projectRoot) {
|
|
4587
4769
|
const read = (rel) => {
|
|
4588
4770
|
try {
|
|
4589
|
-
return
|
|
4771
|
+
return readFileSync20(join24(projectRoot, ...rel.split("/")), "utf8");
|
|
4590
4772
|
} catch {
|
|
4591
4773
|
return null;
|
|
4592
4774
|
}
|
|
4593
4775
|
};
|
|
4594
|
-
const statePath =
|
|
4595
|
-
if (
|
|
4776
|
+
const statePath = join24(projectRoot, ".jig", "state.json");
|
|
4777
|
+
if (existsSync15(statePath)) {
|
|
4596
4778
|
try {
|
|
4597
|
-
const state = JSON.parse(
|
|
4779
|
+
const state = JSON.parse(readFileSync20(statePath, "utf8"));
|
|
4598
4780
|
const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
|
|
4599
4781
|
const out = recorded.map((path) => ({ path, source: read(path) })).filter((f) => f.source !== null);
|
|
4600
4782
|
if (out.length > 0) return out;
|
|
4601
4783
|
} catch {
|
|
4602
4784
|
}
|
|
4603
4785
|
}
|
|
4604
|
-
const legacy =
|
|
4605
|
-
if (!
|
|
4606
|
-
return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source:
|
|
4786
|
+
const legacy = join24(projectRoot, ".jig", "tokens");
|
|
4787
|
+
if (!existsSync15(legacy)) return [];
|
|
4788
|
+
return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync20(join24(legacy, f), "utf8") }));
|
|
4607
4789
|
}
|
|
4608
4790
|
function auditTokenLayer(projectRoot) {
|
|
4609
4791
|
const files = tokenFiles(projectRoot);
|
|
@@ -4671,15 +4853,18 @@ function auditTokenLayer(projectRoot) {
|
|
|
4671
4853
|
}
|
|
4672
4854
|
|
|
4673
4855
|
// src/commands/check.ts
|
|
4674
|
-
|
|
4856
|
+
function checkExitCode(result) {
|
|
4857
|
+
return result.hasError ? 1 : 0;
|
|
4858
|
+
}
|
|
4859
|
+
var LEGACY_INDEX_REL = join25(".jig", "rules.index.json");
|
|
4675
4860
|
function resolveIndexPath(projectRoot) {
|
|
4676
|
-
const legacy =
|
|
4677
|
-
if (
|
|
4678
|
-
return
|
|
4861
|
+
const legacy = join25(projectRoot, LEGACY_INDEX_REL);
|
|
4862
|
+
if (existsSync16(legacy)) return legacy;
|
|
4863
|
+
return join25(assetRoot(), "rules.index.json");
|
|
4679
4864
|
}
|
|
4680
4865
|
function resolveMode(projectRoot) {
|
|
4681
4866
|
try {
|
|
4682
|
-
const config = JSON.parse(
|
|
4867
|
+
const config = JSON.parse(readFileSync21(join25(projectRoot, "jig.config.json"), "utf8"));
|
|
4683
4868
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
4684
4869
|
if (modes.length === 1) return modes[0];
|
|
4685
4870
|
return modes.length > 1 ? "mixed" : "unknown";
|
|
@@ -4696,24 +4881,25 @@ function summariseUnscanned(files) {
|
|
|
4696
4881
|
}
|
|
4697
4882
|
function countSpecs() {
|
|
4698
4883
|
try {
|
|
4699
|
-
return loadSpecs(
|
|
4884
|
+
return loadSpecs(join25(assetRoot(), "rules")).length;
|
|
4700
4885
|
} catch {
|
|
4701
4886
|
return 0;
|
|
4702
4887
|
}
|
|
4703
4888
|
}
|
|
4704
4889
|
function check(opts) {
|
|
4705
4890
|
const indexPath = resolveIndexPath(opts.projectRoot);
|
|
4706
|
-
const index = validateIndex(JSON.parse(
|
|
4891
|
+
const index = validateIndex(JSON.parse(readFileSync21(indexPath, "utf8")));
|
|
4707
4892
|
const selection = selectFiles(opts.projectRoot, opts.all);
|
|
4708
|
-
const exemptions = readExemptions(opts.projectRoot);
|
|
4709
|
-
const { scanned: files, exempt, byPattern } = applyExemptions(selection.files, exemptions);
|
|
4893
|
+
const { patterns: exemptions, ignored: exemptIgnored } = readExemptions(opts.projectRoot);
|
|
4894
|
+
const { scanned: files, exempt, byPattern: counted } = applyExemptions(selection.files, exemptions);
|
|
4895
|
+
const byPattern = counted.map((p) => ({ ...p, suggest: p.count === 0 && selection.mode !== "changed" ? suggestPaths(p.pattern, selection.files) : [] }));
|
|
4710
4896
|
const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
|
|
4711
4897
|
const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
|
|
4712
4898
|
const tokens = loadTokenMap(opts.projectRoot, stylesheets);
|
|
4713
4899
|
const projectParticipates = stylesheets.some((f) => {
|
|
4714
4900
|
if (!hasExtension(f, CSS_EXTENSIONS)) return false;
|
|
4715
4901
|
try {
|
|
4716
|
-
return participatesInTokenLayer(
|
|
4902
|
+
return participatesInTokenLayer(readFileSync21(join25(opts.projectRoot, f), "utf8"), tokens);
|
|
4717
4903
|
} catch {
|
|
4718
4904
|
return false;
|
|
4719
4905
|
}
|
|
@@ -4721,7 +4907,7 @@ function check(opts) {
|
|
|
4721
4907
|
const projectResponsive = stylesheets.some((f) => {
|
|
4722
4908
|
if (!isStyleBearing(f)) return false;
|
|
4723
4909
|
try {
|
|
4724
|
-
const raw =
|
|
4910
|
+
const raw = readFileSync21(join25(opts.projectRoot, f), "utf8");
|
|
4725
4911
|
return isResponsive(maskComments(maskNonStyleRegions(raw, f)), raw);
|
|
4726
4912
|
} catch {
|
|
4727
4913
|
return false;
|
|
@@ -4730,14 +4916,14 @@ function check(opts) {
|
|
|
4730
4916
|
const viewportFitCover = stylesheets.some((f) => {
|
|
4731
4917
|
if (!isStyleBearing(f)) return false;
|
|
4732
4918
|
try {
|
|
4733
|
-
return /viewport-fit\s*=\s*cover|viewportFit\s*:\s*['"]cover['"]/i.test(
|
|
4919
|
+
return /viewport-fit\s*=\s*cover|viewportFit\s*:\s*['"]cover['"]/i.test(readFileSync21(join25(opts.projectRoot, f), "utf8"));
|
|
4734
4920
|
} catch {
|
|
4735
4921
|
return false;
|
|
4736
4922
|
}
|
|
4737
4923
|
});
|
|
4738
4924
|
const projectMenuToggle = stylesheets.some((f) => {
|
|
4739
4925
|
try {
|
|
4740
|
-
return hasMenuToggle(
|
|
4926
|
+
return hasMenuToggle(readFileSync21(join25(opts.projectRoot, f), "utf8"));
|
|
4741
4927
|
} catch {
|
|
4742
4928
|
return false;
|
|
4743
4929
|
}
|
|
@@ -4745,8 +4931,10 @@ function check(opts) {
|
|
|
4745
4931
|
const declaredProperties = collectDeclaredProperties(opts.projectRoot, stylesheets, Object.keys(tokens));
|
|
4746
4932
|
const withStyles = files.filter((f) => {
|
|
4747
4933
|
try {
|
|
4748
|
-
const src =
|
|
4749
|
-
|
|
4934
|
+
const src = readFileSync21(join25(opts.projectRoot, f), "utf8");
|
|
4935
|
+
if (hasExtension(f, CSS_EXTENSIONS)) return src.trim().length > 0;
|
|
4936
|
+
if (maskNonStyleRegions(src, f).trim().length > 0) return true;
|
|
4937
|
+
return isMarkupHost(f) && classAttributeValues(src).some((a) => a.classes.trim().length > 0);
|
|
4750
4938
|
} catch {
|
|
4751
4939
|
return false;
|
|
4752
4940
|
}
|
|
@@ -4778,7 +4966,7 @@ function check(opts) {
|
|
|
4778
4966
|
// told to add a token @import it has nowhere to put.
|
|
4779
4967
|
cssFiles.length > 0 && cssFiles.every((f) => {
|
|
4780
4968
|
try {
|
|
4781
|
-
return !participatesInTokenLayer(
|
|
4969
|
+
return !participatesInTokenLayer(readFileSync21(join25(opts.projectRoot, f), "utf8"), tokens);
|
|
4782
4970
|
} catch {
|
|
4783
4971
|
return true;
|
|
4784
4972
|
}
|
|
@@ -4798,20 +4986,22 @@ function check(opts) {
|
|
|
4798
4986
|
withStyles,
|
|
4799
4987
|
exempt,
|
|
4800
4988
|
exemptPatterns: byPattern,
|
|
4989
|
+
exemptIgnored,
|
|
4801
4990
|
scope: selection.mode,
|
|
4802
4991
|
modeUnwired: modeWiringProblems(opts.projectRoot),
|
|
4992
|
+
agentFiles: agentFileProblems(opts.projectRoot, opts.homeDir || void 0),
|
|
4803
4993
|
waived
|
|
4804
4994
|
});
|
|
4805
4995
|
return { findings, report: report2, hasError, waived };
|
|
4806
4996
|
}
|
|
4807
4997
|
|
|
4808
4998
|
// src/commands/init.ts
|
|
4809
|
-
import { existsSync as
|
|
4810
|
-
import { dirname as dirname7, isAbsolute, join as
|
|
4999
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync25, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
5000
|
+
import { dirname as dirname7, isAbsolute, join as join29, resolve as resolve2, sep as sep3 } from "path";
|
|
4811
5001
|
|
|
4812
5002
|
// src/init/detect.ts
|
|
4813
|
-
import { readFileSync as
|
|
4814
|
-
import { join as
|
|
5003
|
+
import { readFileSync as readFileSync22 } from "fs";
|
|
5004
|
+
import { join as join26 } from "path";
|
|
4815
5005
|
var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
|
|
4816
5006
|
var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
|
|
4817
5007
|
var TAILWIND_V4_THEME_RE = /@theme\b/;
|
|
@@ -4828,7 +5018,7 @@ var FRAMEWORK_DEPS = [
|
|
|
4828
5018
|
function detectFramework(projectRoot) {
|
|
4829
5019
|
let pkg;
|
|
4830
5020
|
try {
|
|
4831
|
-
pkg = JSON.parse(
|
|
5021
|
+
pkg = JSON.parse(readFileSync22(join26(projectRoot, "package.json"), "utf8"));
|
|
4832
5022
|
} catch {
|
|
4833
5023
|
return void 0;
|
|
4834
5024
|
}
|
|
@@ -4846,7 +5036,7 @@ function detect(projectRoot) {
|
|
|
4846
5036
|
for (const f of cssFiles) {
|
|
4847
5037
|
let source;
|
|
4848
5038
|
try {
|
|
4849
|
-
source =
|
|
5039
|
+
source = readFileSync22(join26(projectRoot, f), "utf8");
|
|
4850
5040
|
} catch {
|
|
4851
5041
|
continue;
|
|
4852
5042
|
}
|
|
@@ -4881,8 +5071,8 @@ function isIgnored(projectRoot, relPath) {
|
|
|
4881
5071
|
}
|
|
4882
5072
|
|
|
4883
5073
|
// src/init/derive.ts
|
|
4884
|
-
import { readFileSync as
|
|
4885
|
-
import { join as
|
|
5074
|
+
import { readFileSync as readFileSync23 } from "fs";
|
|
5075
|
+
import { join as join27 } from "path";
|
|
4886
5076
|
var DEFAULT_PROPOSAL = {
|
|
4887
5077
|
h: 264,
|
|
4888
5078
|
s: 0,
|
|
@@ -4915,7 +5105,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
|
|
|
4915
5105
|
for (const file of cssFiles) {
|
|
4916
5106
|
let source;
|
|
4917
5107
|
try {
|
|
4918
|
-
source =
|
|
5108
|
+
source = readFileSync23(join27(projectRoot, file), "utf8");
|
|
4919
5109
|
} catch {
|
|
4920
5110
|
continue;
|
|
4921
5111
|
}
|
|
@@ -4964,7 +5154,7 @@ function balancedBraceBody(source, openIdx) {
|
|
|
4964
5154
|
function fromTailwindConfig(projectRoot, tailwindConfigFile) {
|
|
4965
5155
|
let source;
|
|
4966
5156
|
try {
|
|
4967
|
-
source =
|
|
5157
|
+
source = readFileSync23(join27(projectRoot, tailwindConfigFile), "utf8");
|
|
4968
5158
|
} catch {
|
|
4969
5159
|
return null;
|
|
4970
5160
|
}
|
|
@@ -5020,7 +5210,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
|
|
|
5020
5210
|
for (const file of cssFiles) {
|
|
5021
5211
|
let source;
|
|
5022
5212
|
try {
|
|
5023
|
-
source =
|
|
5213
|
+
source = readFileSync23(join27(projectRoot, file), "utf8");
|
|
5024
5214
|
} catch {
|
|
5025
5215
|
continue;
|
|
5026
5216
|
}
|
|
@@ -5180,8 +5370,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
|
|
|
5180
5370
|
}
|
|
5181
5371
|
|
|
5182
5372
|
// src/init/project-name.ts
|
|
5183
|
-
import { readFileSync as
|
|
5184
|
-
import { basename, join as
|
|
5373
|
+
import { readFileSync as readFileSync24 } from "fs";
|
|
5374
|
+
import { basename, join as join28 } from "path";
|
|
5185
5375
|
function slugify(name) {
|
|
5186
5376
|
const withoutScope = name.replace(/^@[^/]+\//, "");
|
|
5187
5377
|
const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -5189,7 +5379,7 @@ function slugify(name) {
|
|
|
5189
5379
|
}
|
|
5190
5380
|
function deriveProjectSlug(projectRoot) {
|
|
5191
5381
|
try {
|
|
5192
|
-
const pkg = JSON.parse(
|
|
5382
|
+
const pkg = JSON.parse(readFileSync24(join28(projectRoot, "package.json"), "utf8"));
|
|
5193
5383
|
if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
|
|
5194
5384
|
} catch {
|
|
5195
5385
|
}
|
|
@@ -5330,7 +5520,7 @@ function parseSurfaces(answer, fallback) {
|
|
|
5330
5520
|
return parsed.length > 0 ? parsed : fallback;
|
|
5331
5521
|
}
|
|
5332
5522
|
function fileState(projectRoot, absPath, relPath, initManifest) {
|
|
5333
|
-
const existsOnDisk =
|
|
5523
|
+
const existsOnDisk = existsSync17(absPath);
|
|
5334
5524
|
const tracked = !!initManifest && relPath in initManifest.files;
|
|
5335
5525
|
const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
|
|
5336
5526
|
return { existsOnDisk, tracked, modified };
|
|
@@ -5341,11 +5531,11 @@ function isValidSurfaceArray(v) {
|
|
|
5341
5531
|
);
|
|
5342
5532
|
}
|
|
5343
5533
|
function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
|
|
5344
|
-
if (!
|
|
5534
|
+
if (!existsSync17(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
5345
5535
|
try {
|
|
5346
|
-
const parsed = JSON.parse(
|
|
5536
|
+
const parsed = JSON.parse(readFileSync25(configAbsPath, "utf8"));
|
|
5347
5537
|
const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
|
|
5348
|
-
const brandResolves = brandCandidate ?
|
|
5538
|
+
const brandResolves = brandCandidate ? existsSync17(join29(projectRoot, ...brandCandidate.split("/"))) : false;
|
|
5349
5539
|
return {
|
|
5350
5540
|
brand: brandResolves ? brandCandidate : fallbackBrand,
|
|
5351
5541
|
surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
|
|
@@ -5355,10 +5545,10 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
|
|
|
5355
5545
|
}
|
|
5356
5546
|
}
|
|
5357
5547
|
function configuredBrandPath(projectRoot, configAbsPath) {
|
|
5358
|
-
if (!
|
|
5548
|
+
if (!existsSync17(configAbsPath)) return { path: null };
|
|
5359
5549
|
let declared;
|
|
5360
5550
|
try {
|
|
5361
|
-
declared = JSON.parse(
|
|
5551
|
+
declared = JSON.parse(readFileSync25(configAbsPath, "utf8")).brand;
|
|
5362
5552
|
} catch {
|
|
5363
5553
|
return { path: null };
|
|
5364
5554
|
}
|
|
@@ -5386,9 +5576,9 @@ function defaultTokenDir(detection) {
|
|
|
5386
5576
|
return ["jig"];
|
|
5387
5577
|
}
|
|
5388
5578
|
function readConfiguredSurfaces(configAbsPath) {
|
|
5389
|
-
if (!
|
|
5579
|
+
if (!existsSync17(configAbsPath)) return null;
|
|
5390
5580
|
try {
|
|
5391
|
-
const parsed = JSON.parse(
|
|
5581
|
+
const parsed = JSON.parse(readFileSync25(configAbsPath, "utf8"));
|
|
5392
5582
|
return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
|
|
5393
5583
|
} catch {
|
|
5394
5584
|
return null;
|
|
@@ -5398,8 +5588,8 @@ function isCssModule(path) {
|
|
|
5398
5588
|
return /\.module\.(css|scss)$/i.test(path);
|
|
5399
5589
|
}
|
|
5400
5590
|
function legacyBrandFile(projectRoot) {
|
|
5401
|
-
const dir =
|
|
5402
|
-
if (!
|
|
5591
|
+
const dir = join29(projectRoot, ".jig", "tokens");
|
|
5592
|
+
if (!existsSync17(dir)) return void 0;
|
|
5403
5593
|
const found = readdirSync10(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
|
|
5404
5594
|
return found ? relKey(".jig", "tokens", found) : void 0;
|
|
5405
5595
|
}
|
|
@@ -5430,7 +5620,7 @@ var LEGACY_PAIR_RE = new RegExp(
|
|
|
5430
5620
|
`@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES2.join("|")})\\.css["'];?\\r?\\n?`
|
|
5431
5621
|
);
|
|
5432
5622
|
function wireBarrel(absPath, barrelImport) {
|
|
5433
|
-
const content =
|
|
5623
|
+
const content = existsSync17(absPath) ? readFileSync25(absPath, "utf8") : "";
|
|
5434
5624
|
const line = `@import "${barrelImport}";`;
|
|
5435
5625
|
if (content.includes(line)) return { status: "already-present" };
|
|
5436
5626
|
const legacy = LEGACY_PAIR_RE.exec(content);
|
|
@@ -5525,7 +5715,7 @@ async function init(opts) {
|
|
|
5525
5715
|
else if (critiqueAnswer) log(` Could not read '${critiqueAnswer}' \u2014 leaving it page by page; each spec will ask.`);
|
|
5526
5716
|
}
|
|
5527
5717
|
if (opts.yes) {
|
|
5528
|
-
const declared = readConfiguredSurfaces(
|
|
5718
|
+
const declared = readConfiguredSurfaces(join29(opts.projectRoot, "jig.config.json"));
|
|
5529
5719
|
const inEffect = declared ?? surfaces;
|
|
5530
5720
|
const map = inEffect.map((x) => `'${x.match}' \u2192 ${x.mode}`).join(", ");
|
|
5531
5721
|
log(
|
|
@@ -5544,7 +5734,7 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
|
|
|
5544
5734
|
const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
|
|
5545
5735
|
const projectSlug = deriveProjectSlug(opts.projectRoot);
|
|
5546
5736
|
const configRelPath = "jig.config.json";
|
|
5547
|
-
const configAbsPath =
|
|
5737
|
+
const configAbsPath = join29(opts.projectRoot, configRelPath);
|
|
5548
5738
|
const configuredBrand = configuredBrandPath(opts.projectRoot, configAbsPath);
|
|
5549
5739
|
if (configuredBrand.refused) {
|
|
5550
5740
|
log(
|
|
@@ -5578,7 +5768,7 @@ The token layer is at ${currentDir}/, and this project's layout suggests ${would
|
|
|
5578
5768
|
}
|
|
5579
5769
|
}
|
|
5580
5770
|
const brandRelPath = relocateTo ?? configuredBrand.path ?? priorBrand ?? relKey(...defaultTokenDir(detection), brandFileName(projectSlug));
|
|
5581
|
-
const brandAbsPath =
|
|
5771
|
+
const brandAbsPath = join29(opts.projectRoot, ...brandRelPath.split("/"));
|
|
5582
5772
|
const tokensRelDir = brandRelPath.split("/").slice(0, -1);
|
|
5583
5773
|
log(
|
|
5584
5774
|
`Token layer: ${tokensRelDir.join("/") || "."}/ \u2014 ` + (configuredBrand.path ? "from jig.config.json." : priorBrand ? "the layout this project already had. Set `brand` in jig.config.json to move it." : findWireTarget(detection) ? "beside the stylesheet being wired. Set `brand` in jig.config.json to put it elsewhere." : "no stylesheet found to follow, so the project root. Set `brand` in jig.config.json to move it.")
|
|
@@ -5611,7 +5801,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5611
5801
|
if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
|
|
5612
5802
|
}
|
|
5613
5803
|
if (brandAction === "written") {
|
|
5614
|
-
const defaultCssRaw =
|
|
5804
|
+
const defaultCssRaw = readFileSync25(join29(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
|
|
5615
5805
|
const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
|
|
5616
5806
|
mkdirSync6(dirname7(brandAbsPath), { recursive: true });
|
|
5617
5807
|
writeFileSync4(brandAbsPath, content, "utf8");
|
|
@@ -5619,7 +5809,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5619
5809
|
}
|
|
5620
5810
|
if (relocateTo && configAction !== "written") {
|
|
5621
5811
|
try {
|
|
5622
|
-
const existing = JSON.parse(
|
|
5812
|
+
const existing = JSON.parse(readFileSync25(configAbsPath, "utf8"));
|
|
5623
5813
|
const content = `${JSON.stringify({ ...existing, brand: brandRelPath }, null, 2)}
|
|
5624
5814
|
`;
|
|
5625
5815
|
writeFileSync4(configAbsPath, content, "utf8");
|
|
@@ -5638,12 +5828,12 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5638
5828
|
} else if (configAction === "merged") {
|
|
5639
5829
|
let existing = {};
|
|
5640
5830
|
try {
|
|
5641
|
-
existing = JSON.parse(
|
|
5831
|
+
existing = JSON.parse(readFileSync25(configAbsPath, "utf8"));
|
|
5642
5832
|
} catch {
|
|
5643
5833
|
existing = {};
|
|
5644
5834
|
}
|
|
5645
5835
|
const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
|
|
5646
|
-
const brandStillResolves = existingBrand ?
|
|
5836
|
+
const brandStillResolves = existingBrand ? existsSync17(join29(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
5647
5837
|
const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
|
|
5648
5838
|
const merged = {
|
|
5649
5839
|
...existing,
|
|
@@ -5660,12 +5850,12 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5660
5850
|
}
|
|
5661
5851
|
const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
|
|
5662
5852
|
const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
|
|
5663
|
-
const wiringBrandAbsPath =
|
|
5853
|
+
const wiringBrandAbsPath = join29(opts.projectRoot, ...effectiveConfig.brand.split("/"));
|
|
5664
5854
|
const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
|
|
5665
5855
|
const modeAbsPaths = {};
|
|
5666
5856
|
for (const mode of declaredModes) {
|
|
5667
5857
|
const modeFileName = `mode.${mode}.css`;
|
|
5668
|
-
const modeAbsPath2 =
|
|
5858
|
+
const modeAbsPath2 = join29(opts.projectRoot, ...tokensRelDir, modeFileName);
|
|
5669
5859
|
modeAbsPaths[mode] = modeAbsPath2;
|
|
5670
5860
|
const modeRelPath = relKey(...tokensRelDir, modeFileName);
|
|
5671
5861
|
const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
|
|
@@ -5673,7 +5863,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5673
5863
|
if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
|
|
5674
5864
|
else if (modeState.modified) modeAction = "skipped-edited";
|
|
5675
5865
|
if (modeAction === "written") {
|
|
5676
|
-
const modeSourceRaw =
|
|
5866
|
+
const modeSourceRaw = readFileSync25(join29(opts.packageRoot, "tokens", modeFileName), "utf8");
|
|
5677
5867
|
const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
|
|
5678
5868
|
mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
|
|
5679
5869
|
writeFileSync4(modeAbsPath2, content, "utf8");
|
|
@@ -5688,7 +5878,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5688
5878
|
const barrelFor = (mode) => multiMode ? `theme.${mode}.css` : "theme.css";
|
|
5689
5879
|
for (const mode of declaredModes) {
|
|
5690
5880
|
const rel = relKey(...tokensRelDir, barrelFor(mode));
|
|
5691
|
-
const abs =
|
|
5881
|
+
const abs = join29(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
|
|
5692
5882
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
5693
5883
|
if (state.existsOnDisk && (!state.tracked || state.modified)) {
|
|
5694
5884
|
log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
|
|
@@ -5701,8 +5891,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5701
5891
|
}
|
|
5702
5892
|
if (multiMode) {
|
|
5703
5893
|
const rel = relKey(...tokensRelDir, "theme.css");
|
|
5704
|
-
const abs =
|
|
5705
|
-
if (
|
|
5894
|
+
const abs = join29(opts.projectRoot, ...tokensRelDir, "theme.css");
|
|
5895
|
+
if (existsSync17(abs)) {
|
|
5706
5896
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
5707
5897
|
if (state.tracked && !state.modified) {
|
|
5708
5898
|
rmSync2(abs);
|
|
@@ -5717,7 +5907,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5717
5907
|
const declared = declaredTokenNames(
|
|
5718
5908
|
[brandAbsPath, modeAbsPath].map((f) => {
|
|
5719
5909
|
try {
|
|
5720
|
-
return
|
|
5910
|
+
return readFileSync25(f, "utf8");
|
|
5721
5911
|
} catch {
|
|
5722
5912
|
return "";
|
|
5723
5913
|
}
|
|
@@ -5742,7 +5932,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5742
5932
|
log(` This changes how components are written. Both styles are correct.`);
|
|
5743
5933
|
const answer = (await prompt(" Generate it? [y/N]: ")).toLowerCase();
|
|
5744
5934
|
if (answer === "y" || answer === "yes") {
|
|
5745
|
-
const abs =
|
|
5935
|
+
const abs = join29(opts.projectRoot, ...tokensRelDir, "utilities.css");
|
|
5746
5936
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
5747
5937
|
if (state.existsOnDisk && (!state.tracked || state.modified)) {
|
|
5748
5938
|
log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
|
|
@@ -5753,8 +5943,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5753
5943
|
files[rel] = checksum(content);
|
|
5754
5944
|
const target = findWireTarget(detection);
|
|
5755
5945
|
const importPath = target ? relativeImportPath(
|
|
5756
|
-
dirname7(
|
|
5757
|
-
|
|
5946
|
+
dirname7(join29(opts.projectRoot, target)),
|
|
5947
|
+
join29(opts.projectRoot, ...tokensRelDir, "utilities.css")
|
|
5758
5948
|
) : `./${rel}`;
|
|
5759
5949
|
log(` Wrote ${rel}. Import it in the stylesheet that imports Tailwind:`);
|
|
5760
5950
|
log(` @import "tailwindcss";`);
|
|
@@ -5773,11 +5963,11 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5773
5963
|
writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
|
|
5774
5964
|
if (relocateTo && currentBrand) {
|
|
5775
5965
|
const oldDir = currentBrand.split("/").slice(0, -1);
|
|
5776
|
-
const oldAbsDir =
|
|
5777
|
-
if (
|
|
5966
|
+
const oldAbsDir = join29(opts.projectRoot, ...oldDir);
|
|
5967
|
+
if (existsSync17(oldAbsDir)) {
|
|
5778
5968
|
for (const name of readdirSync10(oldAbsDir).filter((f) => f.endsWith(".css"))) {
|
|
5779
5969
|
const rel = relKey(...oldDir, name);
|
|
5780
|
-
const abs =
|
|
5970
|
+
const abs = join29(oldAbsDir, name);
|
|
5781
5971
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
5782
5972
|
if (!state.tracked || state.modified) {
|
|
5783
5973
|
log(` ${rel} was edited \u2014 left in place. Delete it yourself once you have moved anything you need.`);
|
|
@@ -5791,11 +5981,11 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5791
5981
|
const wireTarget = findWireTarget(detection);
|
|
5792
5982
|
let wiring;
|
|
5793
5983
|
if (multiMode) {
|
|
5794
|
-
const bare2 =
|
|
5984
|
+
const bare2 = join29(opts.projectRoot, ...tokensRelDir, "theme.css");
|
|
5795
5985
|
for (const file of detection.cssFiles.filter((f) => !isTokenLayerFile(f))) {
|
|
5796
|
-
const abs =
|
|
5986
|
+
const abs = join29(opts.projectRoot, file);
|
|
5797
5987
|
try {
|
|
5798
|
-
const before =
|
|
5988
|
+
const before = readFileSync25(abs, "utf8");
|
|
5799
5989
|
const after = before.split("\n").filter((l) => {
|
|
5800
5990
|
const m = /^\s*@import\s+["']([^"']+)["'];?\s*$/.exec(l);
|
|
5801
5991
|
return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare2);
|
|
@@ -5817,21 +6007,21 @@ Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensR
|
|
|
5817
6007
|
snippet: effectiveConfig.surfaces.map((s) => `${s.match} \u2192 ${relKey(...tokensRelDir, barrelFor(s.mode))}`).join("\n")
|
|
5818
6008
|
};
|
|
5819
6009
|
} else if (wireTarget) {
|
|
5820
|
-
const targetAbsDir = dirname7(
|
|
6010
|
+
const targetAbsDir = dirname7(join29(opts.projectRoot, wireTarget));
|
|
5821
6011
|
const barrelImport = relativeImportPath(
|
|
5822
6012
|
targetAbsDir,
|
|
5823
|
-
|
|
6013
|
+
join29(opts.projectRoot, ...tokensRelDir, "theme.css")
|
|
5824
6014
|
);
|
|
5825
6015
|
const snippet = `@import "${barrelImport}";`;
|
|
5826
6016
|
try {
|
|
5827
6017
|
if (relocateTo && currentBrand) {
|
|
5828
|
-
const abs =
|
|
6018
|
+
const abs = join29(opts.projectRoot, wireTarget);
|
|
5829
6019
|
const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
|
|
5830
|
-
const before =
|
|
6020
|
+
const before = existsSync17(abs) ? readFileSync25(abs, "utf8") : "";
|
|
5831
6021
|
const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
|
|
5832
6022
|
if (after !== before) writeFileSync4(abs, after, "utf8");
|
|
5833
6023
|
}
|
|
5834
|
-
const outcome = wireBarrel(
|
|
6024
|
+
const outcome = wireBarrel(join29(opts.projectRoot, wireTarget), barrelImport);
|
|
5835
6025
|
wiring = { target: wireTarget, status: outcome.status, snippet };
|
|
5836
6026
|
const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
|
|
5837
6027
|
log(`
|
|
@@ -5848,7 +6038,7 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5848
6038
|
} else {
|
|
5849
6039
|
const barrelImport = relativeImportPath(
|
|
5850
6040
|
opts.projectRoot,
|
|
5851
|
-
|
|
6041
|
+
join29(opts.projectRoot, ...tokensRelDir, "theme.css")
|
|
5852
6042
|
);
|
|
5853
6043
|
const snippet = `@import "${barrelImport}";`;
|
|
5854
6044
|
wiring = { target: null, status: "print-only", snippet };
|
|
@@ -5857,7 +6047,7 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5857
6047
|
if (pasteTargets.length > 0) {
|
|
5858
6048
|
log("Paste the block for whichever of these is your global stylesheet:");
|
|
5859
6049
|
for (const target of pasteTargets) {
|
|
5860
|
-
const dir = dirname7(
|
|
6050
|
+
const dir = dirname7(join29(opts.projectRoot, target));
|
|
5861
6051
|
const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
|
|
5862
6052
|
@import "${relativeImportPath(dir, modeAbsPath)}";`;
|
|
5863
6053
|
log(`
|
|
@@ -5883,6 +6073,9 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5883
6073
|
"\n WARNING: .jig/ is gitignored. It holds state.json \u2014 which `update` reads to leave your edited files alone \u2014 and the specs, mockups and critique verdicts this project agreed. Ignored, update cannot tell your edits from its own writes, and every spec is invisible to the next agent. Commit it."
|
|
5884
6074
|
);
|
|
5885
6075
|
}
|
|
6076
|
+
const agentFiles = ensureAgentFiles(opts.projectRoot, { claude: claudeInstalled(opts.projectRoot, opts.homeDir) });
|
|
6077
|
+
if (agentFiles.length) log(`
|
|
6078
|
+
Wrote Jig's instructions for your agent into ${agentFiles.join(" and ")}; your own text in them is untouched.`);
|
|
5886
6079
|
const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
|
|
5887
6080
|
log("\nBaseline:");
|
|
5888
6081
|
log(baselineResult.report);
|
|
@@ -5905,28 +6098,28 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5905
6098
|
|
|
5906
6099
|
// src/commands/verdicts.ts
|
|
5907
6100
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
5908
|
-
import { existsSync as
|
|
5909
|
-
import { join as
|
|
6101
|
+
import { existsSync as existsSync24, readFileSync as readFileSync32 } from "fs";
|
|
6102
|
+
import { join as join38, relative as relative4, resolve as resolve5, sep as sep6 } from "path";
|
|
5910
6103
|
|
|
5911
6104
|
// src/rules/citations.ts
|
|
5912
|
-
import { join as
|
|
6105
|
+
import { join as join30 } from "path";
|
|
5913
6106
|
function citableIds(packageRoot2) {
|
|
5914
|
-
const rulesDir =
|
|
6107
|
+
const rulesDir = join30(packageRoot2, "rules");
|
|
5915
6108
|
const ids = [
|
|
5916
|
-
...loadRules(rulesDir,
|
|
6109
|
+
...loadRules(rulesDir, join30(packageRoot2, "rules.index.json")).map((r) => r.id),
|
|
5917
6110
|
...loadSpecs(rulesDir).map((s) => s.id)
|
|
5918
6111
|
];
|
|
5919
6112
|
return [...new Set(ids)].sort();
|
|
5920
6113
|
}
|
|
5921
6114
|
|
|
5922
6115
|
// src/probe/check.ts
|
|
5923
|
-
import { existsSync as
|
|
5924
|
-
import { join as
|
|
6116
|
+
import { existsSync as existsSync21, readdirSync as readdirSync13, readFileSync as readFileSync29 } from "fs";
|
|
6117
|
+
import { join as join35 } from "path";
|
|
5925
6118
|
|
|
5926
6119
|
// src/probe/save.ts
|
|
5927
|
-
import { existsSync as
|
|
6120
|
+
import { existsSync as existsSync20, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync28, writeFileSync as writeFileSync5 } from "fs";
|
|
5928
6121
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5929
|
-
import { join as
|
|
6122
|
+
import { join as join34, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5930
6123
|
|
|
5931
6124
|
// src/probe/script.ts
|
|
5932
6125
|
var PROBE_VERSION = 9;
|
|
@@ -5947,12 +6140,18 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5947
6140
|
// the page's own copy around it still counts. So is a tool's output or a
|
|
5948
6141
|
// command shown in <pre>, <code>, <samp> or <kbd>: jig-site's home page
|
|
5949
6142
|
// quotes \`jig check\`'s real output, whose own wording has an em dash, and
|
|
5950
|
-
// the probe blocked a page for words it had rightly not rewritten.
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
6143
|
+
// the probe blocked a page for words it had rightly not rewritten. The
|
|
6144
|
+
// quotations are hidden while the text is read, not cut out of it by string:
|
|
6145
|
+
// an inline <code>A-01</code> earlier in the prose removed "A-01" from the
|
|
6146
|
+
// output block below it, so the block no longer matched and its dash counted.
|
|
6147
|
+
const quotedEls = [...document.querySelectorAll('blockquote, q, pre, code, samp, kbd')];
|
|
6148
|
+
const hide = document.createElement('style');
|
|
6149
|
+
hide.textContent = '[data-jig-quoted] { display: none !important; }';
|
|
6150
|
+
for (const q of quotedEls) q.setAttribute('data-jig-quoted', '');
|
|
6151
|
+
document.head.appendChild(hide);
|
|
6152
|
+
const ownText = document.body.innerText || '';
|
|
6153
|
+
hide.remove();
|
|
6154
|
+
for (const q of quotedEls) q.removeAttribute('data-jig-quoted');
|
|
5956
6155
|
const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
|
|
5957
6156
|
const unresolved = new Set();
|
|
5958
6157
|
for (const sheet of document.styleSheets) {
|
|
@@ -6122,6 +6321,17 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6122
6321
|
fauxSeen.add(key);
|
|
6123
6322
|
fauxFaces.push({ family: first.trim().replace(/^["']|["']$/g, ''), weight, style: italic ? 'italic' : 'normal', text: (el.innerText || '').trim().slice(0, 40) });
|
|
6124
6323
|
}
|
|
6324
|
+
// M-01: \`editorial\` allows no box that scrolls sideways on a phone; a code
|
|
6325
|
+
// block wraps and a wide table stacks (L-01, step 6). In a control run a
|
|
6326
|
+
// spec said the code would wrap and the built page scrolled it, and nothing
|
|
6327
|
+
// measured the box: the page itself was not wider than the screen.
|
|
6328
|
+
const sidewaysBoxes = [];
|
|
6329
|
+
for (const el of [...document.body.querySelectorAll('*')].slice(0, 4000)) {
|
|
6330
|
+
if (sidewaysBoxes.length >= 5) break;
|
|
6331
|
+
const ox = getComputedStyle(el).overflowX;
|
|
6332
|
+
if ((ox !== 'auto' && ox !== 'scroll') || el.scrollWidth <= el.clientWidth + 1 || !vis(el)) continue;
|
|
6333
|
+
sidewaysBoxes.push({ box: el.tagName.toLowerCase() + (el.classList[0] ? '.' + el.classList[0] : ''), text: (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth });
|
|
6334
|
+
}
|
|
6125
6335
|
return JSON.stringify({
|
|
6126
6336
|
jigProbe: ${PROBE_VERSION},
|
|
6127
6337
|
url: location.href,
|
|
@@ -6129,6 +6339,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6129
6339
|
scrollWidth: doc.scrollWidth,
|
|
6130
6340
|
clientWidth: doc.clientWidth,
|
|
6131
6341
|
sidewaysScroll: doc.scrollWidth > doc.clientWidth,
|
|
6342
|
+
sidewaysBoxes,
|
|
6132
6343
|
bodyFont: getComputedStyle(document.body).fontFamily,
|
|
6133
6344
|
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
6134
6345
|
unresolvedTokens: [...unresolved],
|
|
@@ -6145,6 +6356,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6145
6356
|
canonical: (document.querySelector('link[rel=canonical]') || {}).href || '',
|
|
6146
6357
|
robots: (document.querySelector('meta[name=robots]') || {}).content || '',
|
|
6147
6358
|
ogTitle: (document.querySelector('meta[property="og:title"]') || {}).content || '',
|
|
6359
|
+
ogDescription: (document.querySelector('meta[property="og:description"]') || {}).content || '',
|
|
6148
6360
|
ogImage: (document.querySelector('meta[property="og:image"]') || {}).content || '',
|
|
6149
6361
|
},
|
|
6150
6362
|
contentWidth: Math.round(region.getBoundingClientRect().width),
|
|
@@ -6158,24 +6370,28 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6158
6370
|
|
|
6159
6371
|
// src/probe/browser.ts
|
|
6160
6372
|
import { spawn } from "child_process";
|
|
6161
|
-
import { existsSync as
|
|
6162
|
-
import { homedir, tmpdir } from "os";
|
|
6163
|
-
import { join as
|
|
6373
|
+
import { existsSync as existsSync18, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
|
|
6374
|
+
import { homedir as homedir2, tmpdir } from "os";
|
|
6375
|
+
import { join as join31 } from "path";
|
|
6164
6376
|
var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
|
|
6165
|
-
var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
|
|
6377
|
+
var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser", "chrome", "msedge"];
|
|
6166
6378
|
var CHROME_APPS = [
|
|
6167
6379
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
6168
6380
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
6169
6381
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
6170
6382
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
6171
|
-
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
6383
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
6384
|
+
// Every Windows machine has Edge; Chrome is often installed per user.
|
|
6385
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
6386
|
+
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
6387
|
+
...process.env.LOCALAPPDATA ? [join31(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")] : []
|
|
6172
6388
|
];
|
|
6173
6389
|
function cachedBrowsers() {
|
|
6174
6390
|
const roots = [
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6391
|
+
join31(homedir2(), ".cache", "ms-playwright"),
|
|
6392
|
+
join31(homedir2(), "Library", "Caches", "ms-playwright"),
|
|
6393
|
+
join31(homedir2(), "AppData", "Local", "ms-playwright"),
|
|
6394
|
+
join31(homedir2(), ".cache", "puppeteer", "chrome")
|
|
6179
6395
|
];
|
|
6180
6396
|
const found = [];
|
|
6181
6397
|
for (const root of roots) {
|
|
@@ -6195,8 +6411,8 @@ function cachedBrowsers() {
|
|
|
6195
6411
|
["chrome-win", "chrome.exe"],
|
|
6196
6412
|
["chrome-win64", "chrome.exe"]
|
|
6197
6413
|
]) {
|
|
6198
|
-
const candidate =
|
|
6199
|
-
if (
|
|
6414
|
+
const candidate = join31(root, entry, ...rel);
|
|
6415
|
+
if (existsSync18(candidate)) found.push(candidate);
|
|
6200
6416
|
}
|
|
6201
6417
|
}
|
|
6202
6418
|
}
|
|
@@ -6205,21 +6421,25 @@ function cachedBrowsers() {
|
|
|
6205
6421
|
function findChrome() {
|
|
6206
6422
|
for (const key of CHROME_ENV) {
|
|
6207
6423
|
const value = process.env[key];
|
|
6208
|
-
if (value &&
|
|
6424
|
+
if (value && existsSync18(value)) return value;
|
|
6209
6425
|
}
|
|
6210
6426
|
for (const command of CHROME_COMMANDS) {
|
|
6211
6427
|
const resolved = which(command);
|
|
6212
6428
|
if (resolved) return resolved;
|
|
6213
6429
|
}
|
|
6214
|
-
for (const app of CHROME_APPS) if (
|
|
6430
|
+
for (const app of CHROME_APPS) if (existsSync18(app)) return app;
|
|
6215
6431
|
return cachedBrowsers()[0];
|
|
6216
6432
|
}
|
|
6217
6433
|
function which(command) {
|
|
6218
|
-
const
|
|
6434
|
+
const windows = process.platform === "win32";
|
|
6435
|
+
const dirs = (process.env.PATH ?? "").split(windows ? ";" : ":");
|
|
6436
|
+
const exts = windows ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean) : [""];
|
|
6219
6437
|
for (const dir of dirs) {
|
|
6220
6438
|
if (!dir) continue;
|
|
6221
|
-
const
|
|
6222
|
-
|
|
6439
|
+
for (const ext of exts) {
|
|
6440
|
+
const candidate = join31(dir, command + ext);
|
|
6441
|
+
if (existsSync18(candidate)) return candidate;
|
|
6442
|
+
}
|
|
6223
6443
|
}
|
|
6224
6444
|
return void 0;
|
|
6225
6445
|
}
|
|
@@ -6230,7 +6450,7 @@ async function runProbe(opts) {
|
|
|
6230
6450
|
"No browser found to run the probe. Install one, or set JIG_CHROME to a Chrome, Chromium or Edge binary. A project with Playwright or Puppeteer already has one, and Jig will find it."
|
|
6231
6451
|
);
|
|
6232
6452
|
}
|
|
6233
|
-
const profile = mkdtempSync(
|
|
6453
|
+
const profile = mkdtempSync(join31(tmpdir(), "jig-probe-"));
|
|
6234
6454
|
const child = spawn(chrome, [
|
|
6235
6455
|
"--headless=new",
|
|
6236
6456
|
"--remote-debugging-port=0",
|
|
@@ -6274,6 +6494,9 @@ async function runProbe(opts) {
|
|
|
6274
6494
|
}
|
|
6275
6495
|
}
|
|
6276
6496
|
async function evaluate(endpoint, url, width, height, timeoutMs, expression) {
|
|
6497
|
+
if (typeof WebSocket === "undefined") {
|
|
6498
|
+
throw new Error(`rendering a page needs Node 22 or later (this is Node ${process.versions.node}). Upgrade Node, then run it again.`);
|
|
6499
|
+
}
|
|
6277
6500
|
const socket = new WebSocket(endpoint);
|
|
6278
6501
|
let nextId = 1;
|
|
6279
6502
|
const pending = /* @__PURE__ */ new Map();
|
|
@@ -6331,8 +6554,8 @@ async function evaluate(endpoint, url, width, height, timeoutMs, expression) {
|
|
|
6331
6554
|
|
|
6332
6555
|
// src/probe/serve.ts
|
|
6333
6556
|
import { createServer } from "http";
|
|
6334
|
-
import { existsSync as
|
|
6335
|
-
import { extname, join as
|
|
6557
|
+
import { existsSync as existsSync19, readFileSync as readFileSync26, statSync as statSync3 } from "fs";
|
|
6558
|
+
import { extname, join as join32, resolve as resolve3, sep as sep4 } from "path";
|
|
6336
6559
|
var TYPES = {
|
|
6337
6560
|
".html": "text/html; charset=utf-8",
|
|
6338
6561
|
".htm": "text/html; charset=utf-8",
|
|
@@ -6359,19 +6582,19 @@ async function serveDirectory(dir) {
|
|
|
6359
6582
|
const root = resolve3(dir);
|
|
6360
6583
|
const server = createServer((req, res) => {
|
|
6361
6584
|
let path = decodeURIComponent((req.url ?? "/").split("?")[0].split("#")[0]);
|
|
6362
|
-
let file = resolve3(
|
|
6585
|
+
let file = resolve3(join32(root, path));
|
|
6363
6586
|
if (file !== root && !file.startsWith(root + sep4)) {
|
|
6364
6587
|
res.writeHead(403).end();
|
|
6365
6588
|
return;
|
|
6366
6589
|
}
|
|
6367
|
-
if (
|
|
6368
|
-
else if (!
|
|
6369
|
-
if (!
|
|
6590
|
+
if (existsSync19(file) && statSync3(file).isDirectory()) file = join32(file, "index.html");
|
|
6591
|
+
else if (!existsSync19(file) && existsSync19(`${file}.html`)) file = `${file}.html`;
|
|
6592
|
+
if (!existsSync19(file)) {
|
|
6370
6593
|
res.writeHead(404).end();
|
|
6371
6594
|
return;
|
|
6372
6595
|
}
|
|
6373
6596
|
res.writeHead(200, { "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream" });
|
|
6374
|
-
res.end(
|
|
6597
|
+
res.end(readFileSync26(file));
|
|
6375
6598
|
});
|
|
6376
6599
|
await new Promise((done) => server.listen(0, "127.0.0.1", done));
|
|
6377
6600
|
const address = server.address();
|
|
@@ -6383,8 +6606,8 @@ async function serveDirectory(dir) {
|
|
|
6383
6606
|
}
|
|
6384
6607
|
|
|
6385
6608
|
// src/probe/switches.ts
|
|
6386
|
-
import { readFileSync as
|
|
6387
|
-
import { join as
|
|
6609
|
+
import { readFileSync as readFileSync27 } from "fs";
|
|
6610
|
+
import { join as join33 } from "path";
|
|
6388
6611
|
var DECLARATION = /--breakpoint-([a-z0-9-]+)\s*:\s*(\d+(?:\.\d+)?)(px|rem)\b/g;
|
|
6389
6612
|
function declaredSwitches(projectRoot) {
|
|
6390
6613
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -6392,7 +6615,7 @@ function declaredSwitches(projectRoot) {
|
|
|
6392
6615
|
if (!/\.(css|scss|astro|vue|svelte|html)$/i.test(file)) continue;
|
|
6393
6616
|
let text;
|
|
6394
6617
|
try {
|
|
6395
|
-
text =
|
|
6618
|
+
text = readFileSync27(join33(projectRoot, file), "utf8");
|
|
6396
6619
|
} catch {
|
|
6397
6620
|
continue;
|
|
6398
6621
|
}
|
|
@@ -6427,18 +6650,18 @@ function saveProbe(opts) {
|
|
|
6427
6650
|
}
|
|
6428
6651
|
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
6429
6652
|
if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
|
|
6430
|
-
probe.pageChecksum = checksum(
|
|
6653
|
+
probe.pageChecksum = checksum(readFileSync28(page, "utf8"));
|
|
6431
6654
|
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6432
|
-
const dir =
|
|
6655
|
+
const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6433
6656
|
mkdirSync7(dir, { recursive: true });
|
|
6434
|
-
const path =
|
|
6657
|
+
const path = join34(dir, `probe-${width}.json`);
|
|
6435
6658
|
writeFileSync5(path, JSON.stringify(probe), "utf8");
|
|
6436
6659
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
6437
6660
|
}
|
|
6438
6661
|
function projectFile(projectRoot, path) {
|
|
6439
6662
|
const abs = resolve4(projectRoot, path);
|
|
6440
6663
|
if (abs !== resolve4(projectRoot) && !abs.startsWith(resolve4(projectRoot) + sep5)) return void 0;
|
|
6441
|
-
return
|
|
6664
|
+
return existsSync20(abs) ? abs : void 0;
|
|
6442
6665
|
}
|
|
6443
6666
|
function pageFile(projectRoot, url) {
|
|
6444
6667
|
if (!url.startsWith("file://")) return void 0;
|
|
@@ -6450,12 +6673,12 @@ function pageFile(projectRoot, url) {
|
|
|
6450
6673
|
}
|
|
6451
6674
|
const abs = resolve4(path);
|
|
6452
6675
|
if (!abs.startsWith(resolve4(projectRoot))) return void 0;
|
|
6453
|
-
return
|
|
6676
|
+
return existsSync20(abs) ? abs : void 0;
|
|
6454
6677
|
}
|
|
6455
6678
|
var PROBE_WIDTHS = [360, 768, 1280, 1600];
|
|
6456
6679
|
async function runAndSaveProbes(opts) {
|
|
6457
6680
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6458
|
-
if (!
|
|
6681
|
+
if (!existsSync20(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
|
|
6459
6682
|
const saved = [];
|
|
6460
6683
|
const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
|
|
6461
6684
|
await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
|
|
@@ -6469,7 +6692,7 @@ async function runAndSaveProbes(opts) {
|
|
|
6469
6692
|
async function withPageUrl(projectRoot, abs, serve, use) {
|
|
6470
6693
|
if (!serve) return use(`file://${abs}`);
|
|
6471
6694
|
const root = resolve4(projectRoot, serve);
|
|
6472
|
-
if (!
|
|
6695
|
+
if (!existsSync20(root)) throw new Error(`${serve} does not exist, so there is nothing to serve.`);
|
|
6473
6696
|
if (!abs.startsWith(root + sep5)) throw new Error(`The page is not inside ${serve}, so serving ${serve} cannot show it.`);
|
|
6474
6697
|
const server = await serveDirectory(root);
|
|
6475
6698
|
try {
|
|
@@ -6480,31 +6703,31 @@ async function withPageUrl(projectRoot, abs, serve, use) {
|
|
|
6480
6703
|
}
|
|
6481
6704
|
}
|
|
6482
6705
|
function recordedPage(projectRoot, surface) {
|
|
6483
|
-
const dir =
|
|
6484
|
-
if (!
|
|
6706
|
+
const dir = join34(projectRoot, ".jig", "critique", surface);
|
|
6707
|
+
if (!existsSync20(dir)) return void 0;
|
|
6485
6708
|
for (const f of readdirSync12(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
|
|
6486
6709
|
try {
|
|
6487
|
-
const probe = JSON.parse(
|
|
6488
|
-
if (probe.pageFile &&
|
|
6710
|
+
const probe = JSON.parse(readFileSync28(join34(dir, f), "utf8"));
|
|
6711
|
+
if (probe.pageFile && existsSync20(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
|
|
6489
6712
|
} catch {
|
|
6490
6713
|
}
|
|
6491
6714
|
}
|
|
6492
6715
|
return void 0;
|
|
6493
6716
|
}
|
|
6494
6717
|
async function ensureProbes(opts) {
|
|
6495
|
-
const dir =
|
|
6718
|
+
const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6496
6719
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6497
6720
|
let current;
|
|
6498
6721
|
try {
|
|
6499
|
-
current = checksum(
|
|
6722
|
+
current = checksum(readFileSync28(abs, "utf8"));
|
|
6500
6723
|
} catch {
|
|
6501
6724
|
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
6502
6725
|
}
|
|
6503
|
-
const recorded =
|
|
6726
|
+
const recorded = existsSync20(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
|
|
6504
6727
|
const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
|
|
6505
6728
|
const missing = widths.filter((width) => {
|
|
6506
6729
|
try {
|
|
6507
|
-
const probe = JSON.parse(
|
|
6730
|
+
const probe = JSON.parse(readFileSync28(join34(dir, `probe-${width}.json`), "utf8"));
|
|
6508
6731
|
return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
|
|
6509
6732
|
} catch {
|
|
6510
6733
|
return true;
|
|
@@ -6515,7 +6738,7 @@ async function ensureProbes(opts) {
|
|
|
6515
6738
|
let serve;
|
|
6516
6739
|
for (const width of widths) {
|
|
6517
6740
|
try {
|
|
6518
|
-
const probe = JSON.parse(
|
|
6741
|
+
const probe = JSON.parse(readFileSync28(join34(dir, `probe-${width}.json`), "utf8"));
|
|
6519
6742
|
if (probe.serveRoot) {
|
|
6520
6743
|
serve = probe.serveRoot;
|
|
6521
6744
|
break;
|
|
@@ -6531,9 +6754,9 @@ async function ensureProbes(opts) {
|
|
|
6531
6754
|
return { recorded: missing };
|
|
6532
6755
|
}
|
|
6533
6756
|
function critiquedSurfaces(projectRoot) {
|
|
6534
|
-
const root =
|
|
6535
|
-
if (!
|
|
6536
|
-
return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (
|
|
6757
|
+
const root = join34(projectRoot, ".jig", "critique");
|
|
6758
|
+
if (!existsSync20(root)) return [];
|
|
6759
|
+
return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync20(join34(root, surface, "screen.json")) || existsSync20(join34(root, surface, "code.json"))));
|
|
6537
6760
|
}
|
|
6538
6761
|
|
|
6539
6762
|
// src/probe/check.ts
|
|
@@ -6545,7 +6768,7 @@ function stampProblem(projectRoot, file, p) {
|
|
|
6545
6768
|
if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
|
|
6546
6769
|
let current;
|
|
6547
6770
|
try {
|
|
6548
|
-
current =
|
|
6771
|
+
current = readFileSync29(page, "utf8");
|
|
6549
6772
|
} catch {
|
|
6550
6773
|
return `${file} was taken on ${p.pageFile}, which no longer exists.`;
|
|
6551
6774
|
}
|
|
@@ -6555,11 +6778,11 @@ function stampProblem(projectRoot, file, p) {
|
|
|
6555
6778
|
return void 0;
|
|
6556
6779
|
}
|
|
6557
6780
|
function readProbes(projectRoot, dir, errors) {
|
|
6558
|
-
if (!
|
|
6781
|
+
if (!existsSync21(dir)) return [];
|
|
6559
6782
|
const probes = [];
|
|
6560
6783
|
for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
6561
6784
|
try {
|
|
6562
|
-
const p = JSON.parse(
|
|
6785
|
+
const p = JSON.parse(readFileSync29(join35(dir, f), "utf8"));
|
|
6563
6786
|
if (typeof p.jigProbe === "number" && p.jigProbe < PROBE_VERSION && typeof p.width === "number") {
|
|
6564
6787
|
errors.push(`${f} was taken by an older \`jig probe\` (version ${p.jigProbe}; this Jig reads version ${PROBE_VERSION}). Record it again with \`jig probe --run\`.`);
|
|
6565
6788
|
continue;
|
|
@@ -6582,29 +6805,34 @@ function readProbes(projectRoot, dir, errors) {
|
|
|
6582
6805
|
}
|
|
6583
6806
|
var TITLE_BUDGET2 = 60;
|
|
6584
6807
|
var DESCRIPTION_BUDGET2 = 155;
|
|
6585
|
-
function
|
|
6808
|
+
function probeCheck(probes, verdictOf, indexable = true, mode) {
|
|
6586
6809
|
const errors = [];
|
|
6810
|
+
const failures = [];
|
|
6587
6811
|
const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
|
|
6588
6812
|
const at = (p) => `probe-${p.width}.json`;
|
|
6589
6813
|
for (const p of probes) {
|
|
6590
6814
|
if (p.sidewaysScroll && clean("D-115")) {
|
|
6591
6815
|
errors.push(`D-115 is "${verdictOf("D-115")}", but ${at(p)} measured the page ${p.scrollWidth}px wide in a ${p.clientWidth}px viewport \u2014 it scrolls sideways.`);
|
|
6592
6816
|
}
|
|
6817
|
+
if (mode === "editorial" && p.width <= 480 && p.sidewaysBoxes?.length) {
|
|
6818
|
+
const shown = p.sidewaysBoxes.map((b) => `${b.box}${b.text ? ` ("${b.text}")` : ""}, ${b.scrollWidth}px in ${b.clientWidth}px`).join("; ");
|
|
6819
|
+
failures.push(`${at(p)}: ${p.sidewaysBoxes.length === 1 ? "a box scrolls" : `${p.sidewaysBoxes.length} boxes scroll`} sideways on a phone: ${shown}. \`editorial\` allows no scrolling region on mobile (M-01): a code block wraps its long lines, keeping its line breaks and indents, and a wide table stacks its rows (L-01, step 6).`);
|
|
6820
|
+
}
|
|
6593
6821
|
if (p.defaultFont) {
|
|
6594
|
-
|
|
6822
|
+
failures.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
|
|
6595
6823
|
}
|
|
6596
6824
|
if (p.fauxFaces?.length) {
|
|
6597
6825
|
const shown = p.fauxFaces.map((f) => `${f.family} ${f.weight}${f.style === "italic" ? " italic" : ""} ("${f.text}")`).join(", ");
|
|
6598
|
-
|
|
6826
|
+
failures.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
|
|
6599
6827
|
}
|
|
6600
6828
|
if (p.unresolvedTokens.length) {
|
|
6601
|
-
|
|
6829
|
+
failures.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
|
|
6602
6830
|
}
|
|
6603
6831
|
for (const inv of p.orderInversions ?? []) {
|
|
6604
|
-
|
|
6832
|
+
failures.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
|
|
6605
6833
|
}
|
|
6606
6834
|
if (p.emDashes?.length) {
|
|
6607
|
-
|
|
6835
|
+
failures.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
|
|
6608
6836
|
}
|
|
6609
6837
|
if (clean("B-106") && p.strandedCount) {
|
|
6610
6838
|
const shown = (p.strandedWords ?? []).map((w) => `"${w.word}" (in "${w.text}\u2026")`).join(", ");
|
|
@@ -6618,17 +6846,21 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6618
6846
|
if (head && p.width === probes[0]?.width) {
|
|
6619
6847
|
const noindex = /noindex/i.test(head.robots);
|
|
6620
6848
|
if (indexable && !noindex) {
|
|
6621
|
-
if (!head.title)
|
|
6622
|
-
else if (head.title.length > TITLE_BUDGET2)
|
|
6623
|
-
if (!head.description)
|
|
6624
|
-
else if (head.description.length > DESCRIPTION_BUDGET2)
|
|
6849
|
+
if (!head.title) failures.push(`${at(p)}: the page served no <title> (J-121) \u2014 a search result then shows a truncated URL.`);
|
|
6850
|
+
else if (head.title.length > TITLE_BUDGET2) failures.push(`${at(p)}: the title served is ${head.title.length} characters, past the ${TITLE_BUDGET2} a search result shows (J-122): "${head.title}".`);
|
|
6851
|
+
if (!head.description) failures.push(`${at(p)}: the page served no meta description (J-121) \u2014 the search engine writes one from whatever text it finds first, usually the navigation.`);
|
|
6852
|
+
else if (head.description.length > DESCRIPTION_BUDGET2) failures.push(`${at(p)}: the description served is ${head.description.length} characters, past the ${DESCRIPTION_BUDGET2} (J-122).`);
|
|
6853
|
+
if (typeof head.ogDescription === "string" && (!head.ogTitle || !head.ogDescription)) {
|
|
6854
|
+
const missing = [!head.ogTitle && "og:title", !head.ogDescription && "og:description"].filter(Boolean).join(" and ");
|
|
6855
|
+
failures.push(`${at(p)}: the page served no ${missing} (J-164) \u2014 a link to it, pasted into a message or a post, shows a card built from whatever the page happens to carry.`);
|
|
6856
|
+
}
|
|
6625
6857
|
}
|
|
6626
6858
|
if (!indexable && !noindex) {
|
|
6627
|
-
|
|
6859
|
+
failures.push(`${at(p)}: this page is not indexable, and the page served no noindex (J-123). robots.txt is public and advisory, and is not this.`);
|
|
6628
6860
|
}
|
|
6629
6861
|
}
|
|
6630
6862
|
if (p.junkText.length) {
|
|
6631
|
-
|
|
6863
|
+
failures.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
|
|
6632
6864
|
}
|
|
6633
6865
|
}
|
|
6634
6866
|
const navClean = clean("P-14") || clean("E-61");
|
|
@@ -6659,23 +6891,23 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6659
6891
|
if (broken.length) errors.push(`P-14 is "${verdictOf("P-14")}", but ${at(phone)} operated the menu: ${broken.join("; ")}.`);
|
|
6660
6892
|
}
|
|
6661
6893
|
}
|
|
6662
|
-
return errors;
|
|
6894
|
+
return { contradictions: errors, failures };
|
|
6663
6895
|
}
|
|
6664
6896
|
|
|
6665
6897
|
// src/check/decisions.ts
|
|
6666
|
-
import { existsSync as
|
|
6667
|
-
import { join as
|
|
6898
|
+
import { existsSync as existsSync22, readFileSync as readFileSync30 } from "fs";
|
|
6899
|
+
import { join as join36, posix as posix3 } from "path";
|
|
6668
6900
|
var HEADING4 = /^(#{2,3})\s+(.+?)\s*$/gm;
|
|
6669
6901
|
var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
|
|
6670
6902
|
function decisionsFile(projectRoot) {
|
|
6671
6903
|
const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
|
|
6672
6904
|
try {
|
|
6673
|
-
const brand = JSON.parse(
|
|
6905
|
+
const brand = JSON.parse(readFileSync30(join36(projectRoot, "jig.config.json"), "utf8")).brand;
|
|
6674
6906
|
if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
|
|
6675
6907
|
} catch {
|
|
6676
6908
|
}
|
|
6677
6909
|
for (const candidate of candidates) {
|
|
6678
|
-
if (
|
|
6910
|
+
if (existsSync22(join36(projectRoot, candidate))) return candidate;
|
|
6679
6911
|
}
|
|
6680
6912
|
return void 0;
|
|
6681
6913
|
}
|
|
@@ -6688,7 +6920,7 @@ function decisionHeadings(projectRoot) {
|
|
|
6688
6920
|
if (!path) return headings;
|
|
6689
6921
|
let body;
|
|
6690
6922
|
try {
|
|
6691
|
-
body =
|
|
6923
|
+
body = readText(join36(projectRoot, path));
|
|
6692
6924
|
} catch {
|
|
6693
6925
|
return headings;
|
|
6694
6926
|
}
|
|
@@ -6746,7 +6978,7 @@ function unsourcedReasons(current, before, opts = {}) {
|
|
|
6746
6978
|
}
|
|
6747
6979
|
return problems;
|
|
6748
6980
|
}
|
|
6749
|
-
function quotesNotFrom(current, before, words) {
|
|
6981
|
+
function quotesNotFrom(current, before, words, source = "the owner's words in tweak.json (`change`)") {
|
|
6750
6982
|
const problems = [];
|
|
6751
6983
|
for (const [name, whys] of changedWhys(current, before, true)) {
|
|
6752
6984
|
for (const why of whys) {
|
|
@@ -6754,7 +6986,7 @@ function quotesNotFrom(current, before, words) {
|
|
|
6754
6986
|
if (/inferred/i.test(label)) continue;
|
|
6755
6987
|
const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
|
|
6756
6988
|
if (missing.length) {
|
|
6757
|
-
problems.push(`"${name}": its \`**${label}:**\` quotes "${missing[0]}", which
|
|
6989
|
+
problems.push(`"${name}": its \`**${label}:**\` quotes "${missing[0]}", which ${source} do not say. Quote the owner as they said it, or put your reading under \`**Why (inferred):**\`.`);
|
|
6758
6990
|
break;
|
|
6759
6991
|
}
|
|
6760
6992
|
}
|
|
@@ -6768,25 +7000,25 @@ function quoteHeld(quote, words) {
|
|
|
6768
7000
|
}
|
|
6769
7001
|
|
|
6770
7002
|
// src/check/spec-shape.ts
|
|
6771
|
-
import { existsSync as
|
|
6772
|
-
import { join as
|
|
7003
|
+
import { existsSync as existsSync23, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
|
|
7004
|
+
import { join as join37 } from "path";
|
|
6773
7005
|
function newestSpec(projectRoot) {
|
|
6774
|
-
const dir =
|
|
6775
|
-
if (!
|
|
7006
|
+
const dir = join37(projectRoot, ".jig", "specs");
|
|
7007
|
+
if (!existsSync23(dir)) return void 0;
|
|
6776
7008
|
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
6777
7009
|
if (files.length === 0) return void 0;
|
|
6778
|
-
const newest = files.map((f) => ({ f, at: statSync4(
|
|
6779
|
-
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body:
|
|
7010
|
+
const newest = files.map((f) => ({ f, at: statSync4(join37(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
7011
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readText(join37(dir, newest)) };
|
|
6780
7012
|
}
|
|
6781
7013
|
function specFor(projectRoot, surface) {
|
|
6782
7014
|
if (surface) {
|
|
6783
|
-
const path =
|
|
6784
|
-
if (
|
|
7015
|
+
const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
7016
|
+
if (existsSync23(path)) return { path: `.jig/specs/${surface}.spec.md`, slug: surface, body: readText(path) };
|
|
6785
7017
|
}
|
|
6786
7018
|
return newestSpec(projectRoot);
|
|
6787
7019
|
}
|
|
6788
7020
|
function specProblems(spec) {
|
|
6789
|
-
const parts = spec.body.split(/^---\s*$/m);
|
|
7021
|
+
const parts = lf(spec.body).split(/^---\s*$/m);
|
|
6790
7022
|
const front = parts.length >= 3 ? parts[1] : "";
|
|
6791
7023
|
if (!front.trim()) {
|
|
6792
7024
|
return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
|
|
@@ -6804,7 +7036,7 @@ function specProblems(spec) {
|
|
|
6804
7036
|
if (motion) {
|
|
6805
7037
|
const inline = motion[1].replace(/\s+#.*$/, "").trim();
|
|
6806
7038
|
const items = motion[2].split("\n").map((l) => l.replace(/^[ \t]+-\s*/, "").trim()).filter(Boolean);
|
|
6807
|
-
if (!items.length && !/^none
|
|
7039
|
+
if (!items.length && !/^none\b(\s*$|\s*[—–:,;(-])/i.test(inline)) {
|
|
6808
7040
|
problems.push(`${spec.path}: \`motion:\` is neither \`none\` nor a list. List each movement as \`- <what moves>: <how>, on <trigger>; tells <what the reader learns>\`, or write \`motion: none\`.`);
|
|
6809
7041
|
}
|
|
6810
7042
|
const unsaid = items.filter((i) => !/\bon\b/i.test(i) || !/\btells?\b/i.test(i));
|
|
@@ -6875,6 +7107,41 @@ function specIndexableField(front) {
|
|
|
6875
7107
|
if (value === "false" || value === "no") return false;
|
|
6876
7108
|
return "unreadable";
|
|
6877
7109
|
}
|
|
7110
|
+
var SPEC_FIELDS = [
|
|
7111
|
+
"feature",
|
|
7112
|
+
"surface",
|
|
7113
|
+
"route",
|
|
7114
|
+
"mode",
|
|
7115
|
+
"indexable",
|
|
7116
|
+
"title",
|
|
7117
|
+
"description",
|
|
7118
|
+
"sizes",
|
|
7119
|
+
"switches",
|
|
7120
|
+
"states",
|
|
7121
|
+
"motion",
|
|
7122
|
+
"decisions",
|
|
7123
|
+
"later",
|
|
7124
|
+
"mockup",
|
|
7125
|
+
"mockup_at",
|
|
7126
|
+
"deviations",
|
|
7127
|
+
"critique",
|
|
7128
|
+
"superseded_by",
|
|
7129
|
+
"confirmed"
|
|
7130
|
+
];
|
|
7131
|
+
var topLevelKeys = (body) => [...(body.split(/^---\s*$/m)[1] ?? "").matchAll(/^([A-Za-z_][\w-]*)\s*:/gm)].map((m) => m[1]);
|
|
7132
|
+
function newFieldProblems(spec, then) {
|
|
7133
|
+
spec = { ...spec, body: lf(spec.body) };
|
|
7134
|
+
then = lf(then);
|
|
7135
|
+
const had = new Set(topLevelKeys(then));
|
|
7136
|
+
const known = new Set(SPEC_FIELDS);
|
|
7137
|
+
const added = [...new Set(topLevelKeys(spec.body))].filter((k) => !known.has(k) && !had.has(k));
|
|
7138
|
+
if (!added.length) return [];
|
|
7139
|
+
const near = (k) => SPEC_FIELDS.find((f) => k.includes(f) || f.startsWith(k.replace(/[-_].*$/, "")));
|
|
7140
|
+
return added.map((k) => {
|
|
7141
|
+
const hint = near(k);
|
|
7142
|
+
return `${spec.path}: \`${k}:\` is not a field Jig reads, so nothing checks what it says. ` + (hint ? `If it is \`${hint}:\`, write it there. ` : "") + `Otherwise put it in the prose below the front matter. Jig's fields: ${SPEC_FIELDS.join(", ")}.`;
|
|
7143
|
+
});
|
|
7144
|
+
}
|
|
6878
7145
|
|
|
6879
7146
|
// src/commands/verdicts.ts
|
|
6880
7147
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
@@ -6901,19 +7168,19 @@ function reasonProblems(file, judged, errors) {
|
|
|
6901
7168
|
}
|
|
6902
7169
|
}
|
|
6903
7170
|
function readJson(path, errors) {
|
|
6904
|
-
if (!
|
|
7171
|
+
if (!existsSync24(path)) return null;
|
|
6905
7172
|
try {
|
|
6906
|
-
return JSON.parse(
|
|
7173
|
+
return JSON.parse(readFileSync32(path, "utf8"));
|
|
6907
7174
|
} catch (e) {
|
|
6908
7175
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
6909
7176
|
return {};
|
|
6910
7177
|
}
|
|
6911
7178
|
}
|
|
6912
7179
|
function specIndexable(projectRoot, surface) {
|
|
6913
|
-
const path =
|
|
7180
|
+
const path = join38(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6914
7181
|
let front = "";
|
|
6915
7182
|
try {
|
|
6916
|
-
front =
|
|
7183
|
+
front = readText(path).split(/^---\s*$/m)[1] ?? "";
|
|
6917
7184
|
} catch {
|
|
6918
7185
|
}
|
|
6919
7186
|
const declared = specIndexableField(front);
|
|
@@ -6922,13 +7189,17 @@ function specIndexable(projectRoot, surface) {
|
|
|
6922
7189
|
return mode !== "product" && mode !== "operator";
|
|
6923
7190
|
}
|
|
6924
7191
|
function specNeedsNav(projectRoot, surface) {
|
|
6925
|
-
const path =
|
|
6926
|
-
if (!
|
|
6927
|
-
const front =
|
|
7192
|
+
const path = join38(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
7193
|
+
if (!existsSync24(path)) return false;
|
|
7194
|
+
const front = readText(path).split(/^---\s*$/m)[1] ?? "";
|
|
6928
7195
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
6929
7196
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
6930
7197
|
return navField || navRegion;
|
|
6931
7198
|
}
|
|
7199
|
+
function unreadFields(label, file, known) {
|
|
7200
|
+
if (!file || typeof file !== "object") return [];
|
|
7201
|
+
return Object.entries(file).filter(([k, v]) => !known.includes(k) && v !== null && typeof v === "object" && Object.keys(v).length > 0).map(([k]) => `${label}: \`${k}\` is not a field Jig reads, so nothing counts what it holds. A rule's verdict goes in \`verdicts\`; what no rule names, in \`differences\`; a note, in the report.`);
|
|
7202
|
+
}
|
|
6932
7203
|
function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors, decisionList = []) {
|
|
6933
7204
|
const total = required.length + extraRequired.length;
|
|
6934
7205
|
if (file === null) {
|
|
@@ -6986,6 +7257,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6986
7257
|
} else findings++;
|
|
6987
7258
|
}
|
|
6988
7259
|
}
|
|
7260
|
+
errors.push(...unreadFields(`${name}.json`, file, ["verdicts", "differences", "rendered", "artefacts"]));
|
|
6989
7261
|
const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
|
|
6990
7262
|
if (missing.length) {
|
|
6991
7263
|
errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
|
|
@@ -6996,7 +7268,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6996
7268
|
function checkDecisions(projectRoot, dir, errors) {
|
|
6997
7269
|
const required = decisionNames(projectRoot);
|
|
6998
7270
|
if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
|
|
6999
|
-
const file = readJson(
|
|
7271
|
+
const file = readJson(join38(dir, "decisions.json"), errors);
|
|
7000
7272
|
if (!file) {
|
|
7001
7273
|
errors.push(
|
|
7002
7274
|
`decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
|
|
@@ -7005,6 +7277,7 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
7005
7277
|
}
|
|
7006
7278
|
const list = Array.isArray(file.verdicts) ? file.verdicts : [];
|
|
7007
7279
|
if (!Array.isArray(file.verdicts)) errors.push('decisions.json has no "verdicts" array.');
|
|
7280
|
+
errors.push(...unreadFields("decisions.json", file, ["verdicts"]));
|
|
7008
7281
|
const seen = /* @__PURE__ */ new Set();
|
|
7009
7282
|
const reasons = [];
|
|
7010
7283
|
let findings = 0;
|
|
@@ -7050,7 +7323,7 @@ function decisionsSince(projectRoot, dir, unjudged) {
|
|
|
7050
7323
|
if (!path) return [];
|
|
7051
7324
|
const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
7052
7325
|
try {
|
|
7053
|
-
const verdictFile = relative4(projectRoot,
|
|
7326
|
+
const verdictFile = relative4(projectRoot, join38(dir, "decisions.json")).split(sep6).join("/");
|
|
7054
7327
|
if (git(["status", "--porcelain", "--", verdictFile])) return [];
|
|
7055
7328
|
const judgedIn = git(["log", "-1", "--format=%H", "--", verdictFile]);
|
|
7056
7329
|
if (!judgedIn) return [];
|
|
@@ -7074,7 +7347,7 @@ function decisionsSince(projectRoot, dir, unjudged) {
|
|
|
7074
7347
|
function verifyVerdicts(opts) {
|
|
7075
7348
|
const root = opts.packageRoot ?? assetRoot();
|
|
7076
7349
|
const errors = [];
|
|
7077
|
-
const index = JSON.parse(
|
|
7350
|
+
const index = JSON.parse(readFileSync32(join38(root, "rules.index.json"), "utf8"));
|
|
7078
7351
|
const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
|
|
7079
7352
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
7080
7353
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -7083,9 +7356,9 @@ function verifyVerdicts(opts) {
|
|
|
7083
7356
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
7084
7357
|
]);
|
|
7085
7358
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
7086
|
-
const dir =
|
|
7087
|
-
const screenFile = readJson(
|
|
7088
|
-
const codeFile = readJson(
|
|
7359
|
+
const dir = join38(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
7360
|
+
const screenFile = readJson(join38(dir, "screen.json"), errors);
|
|
7361
|
+
const codeFile = readJson(join38(dir, "code.json"), errors);
|
|
7089
7362
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
7090
7363
|
const decisionList = decisionNames(opts.projectRoot);
|
|
7091
7364
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors, decisionList);
|
|
@@ -7093,7 +7366,7 @@ function verifyVerdicts(opts) {
|
|
|
7093
7366
|
let rendered = false;
|
|
7094
7367
|
if (screenFile && screenFile.rendered === true) {
|
|
7095
7368
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
7096
|
-
const absent = artefacts.filter((a) => !
|
|
7369
|
+
const absent = artefacts.filter((a) => !existsSync24(resolve5(opts.projectRoot, a)));
|
|
7097
7370
|
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
7098
7371
|
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
7099
7372
|
else rendered = true;
|
|
@@ -7112,22 +7385,33 @@ function verifyVerdicts(opts) {
|
|
|
7112
7385
|
const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
|
|
7113
7386
|
return typeof v?.verdict === "string" ? v.verdict : void 0;
|
|
7114
7387
|
};
|
|
7388
|
+
const measured = [];
|
|
7115
7389
|
let specFront = "";
|
|
7116
7390
|
try {
|
|
7117
|
-
specFront =
|
|
7391
|
+
specFront = readText(join38(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`)).split(/^---\s*$/m)[1] ?? "";
|
|
7118
7392
|
} catch {
|
|
7119
7393
|
}
|
|
7120
7394
|
if (specIndexableField(specFront) === "unreadable") {
|
|
7121
7395
|
errors.push(`.jig/specs/${opts.surface}.spec.md: \`indexable:\` is neither true nor false, so this review cannot tell whether the page is meant to be found and does not guess. Write \`indexable: true\` or \`indexable: false\`, and put the reason in the spec's body.`);
|
|
7122
7396
|
} else {
|
|
7123
|
-
|
|
7397
|
+
const mode = /^\s*mode\s*:\s*["']?(\w+)/im.exec(specFront)?.[1]?.toLowerCase();
|
|
7398
|
+
const probed = probeCheck(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface), mode);
|
|
7399
|
+
errors.push(...probed.contradictions);
|
|
7400
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7401
|
+
for (const failure of probed.failures) {
|
|
7402
|
+
const what = failure.replace(/^probe-\d+\.json: /, "");
|
|
7403
|
+
if (!seen.has(what)) {
|
|
7404
|
+
seen.add(what);
|
|
7405
|
+
measured.push(failure);
|
|
7406
|
+
}
|
|
7407
|
+
}
|
|
7124
7408
|
}
|
|
7125
7409
|
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
7126
7410
|
const decisions = checkDecisions(opts.projectRoot, dir, errors);
|
|
7127
7411
|
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
7128
|
-
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}`;
|
|
7412
|
+
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}${measured.length ? ` measured=${measured.length}` : ""}`;
|
|
7129
7413
|
const previous = previousFindings(opts.projectRoot, dir);
|
|
7130
|
-
return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line, ...previous ? { previous } : {} };
|
|
7414
|
+
return { ok: errors.length === 0, errors, screen, code, decisions, rendered, measured, line, ...previous ? { previous } : {} };
|
|
7131
7415
|
}
|
|
7132
7416
|
function previousFindings(projectRoot, dir) {
|
|
7133
7417
|
const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -7149,7 +7433,7 @@ function previousFindings(projectRoot, dir) {
|
|
|
7149
7433
|
const out = /* @__PURE__ */ new Map();
|
|
7150
7434
|
let text;
|
|
7151
7435
|
try {
|
|
7152
|
-
text = at ? git(["show", `${at}:./${rel}/${f}`]) :
|
|
7436
|
+
text = at ? lf(git(["show", `${at}:./${rel}/${f}`])) : readText(join38(dir, f));
|
|
7153
7437
|
} catch {
|
|
7154
7438
|
return out;
|
|
7155
7439
|
}
|
|
@@ -7231,18 +7515,17 @@ function sourceChanges(git, from, to) {
|
|
|
7231
7515
|
}
|
|
7232
7516
|
|
|
7233
7517
|
// src/commands/gate.ts
|
|
7234
|
-
import { existsSync as
|
|
7518
|
+
import { existsSync as existsSync27, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync36, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
|
|
7235
7519
|
import { createHash as createHash3 } from "crypto";
|
|
7236
|
-
import { join as
|
|
7520
|
+
import { join as join43 } from "path";
|
|
7237
7521
|
|
|
7238
7522
|
// src/check/mockup-drawing.ts
|
|
7239
|
-
import {
|
|
7240
|
-
import { join as join38 } from "path";
|
|
7523
|
+
import { join as join39 } from "path";
|
|
7241
7524
|
var FRAMES = [["phone", 360], ["tablet", 768], ["desktop", 1280], ["wide", 1600]];
|
|
7242
7525
|
function mockupDrawingProblems(root, specBody, at) {
|
|
7243
7526
|
let html;
|
|
7244
7527
|
try {
|
|
7245
|
-
html =
|
|
7528
|
+
html = readText(join39(root, at));
|
|
7246
7529
|
} catch {
|
|
7247
7530
|
return [];
|
|
7248
7531
|
}
|
|
@@ -7303,7 +7586,7 @@ function frameRegionProblems(frames, specBody, at) {
|
|
|
7303
7586
|
function approvedDrawingProblems(root, specBody, at) {
|
|
7304
7587
|
let html;
|
|
7305
7588
|
try {
|
|
7306
|
-
html =
|
|
7589
|
+
html = readText(join39(root, at));
|
|
7307
7590
|
} catch {
|
|
7308
7591
|
return [];
|
|
7309
7592
|
}
|
|
@@ -7409,12 +7692,12 @@ function sizeBlock2(sizes, size) {
|
|
|
7409
7692
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
7410
7693
|
|
|
7411
7694
|
// src/commands/ship.ts
|
|
7412
|
-
import { existsSync as
|
|
7413
|
-
import { join as
|
|
7695
|
+
import { existsSync as existsSync25, readdirSync as readdirSync15, readFileSync as readFileSync34 } from "fs";
|
|
7696
|
+
import { join as join41 } from "path";
|
|
7414
7697
|
|
|
7415
7698
|
// src/commands/seo.ts
|
|
7416
|
-
import { readFileSync as
|
|
7417
|
-
import { join as
|
|
7699
|
+
import { readFileSync as readFileSync33 } from "fs";
|
|
7700
|
+
import { join as join40 } from "path";
|
|
7418
7701
|
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
7419
7702
|
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
7420
7703
|
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
@@ -7455,7 +7738,7 @@ function seo(opts) {
|
|
|
7455
7738
|
for (const file of files) {
|
|
7456
7739
|
let raw;
|
|
7457
7740
|
try {
|
|
7458
|
-
raw =
|
|
7741
|
+
raw = readFileSync33(join40(root, file), "utf8");
|
|
7459
7742
|
} catch {
|
|
7460
7743
|
continue;
|
|
7461
7744
|
}
|
|
@@ -7520,7 +7803,7 @@ function critiqueAtShip(projectRoot, specBody) {
|
|
|
7520
7803
|
const own = specCritique(specBody);
|
|
7521
7804
|
if (own) return own === "at-ship";
|
|
7522
7805
|
try {
|
|
7523
|
-
return JSON.parse(
|
|
7806
|
+
return JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
|
|
7524
7807
|
} catch {
|
|
7525
7808
|
return false;
|
|
7526
7809
|
}
|
|
@@ -7528,14 +7811,14 @@ function critiqueAtShip(projectRoot, specBody) {
|
|
|
7528
7811
|
function tweakDeferred(dir) {
|
|
7529
7812
|
let t;
|
|
7530
7813
|
try {
|
|
7531
|
-
t = JSON.parse(
|
|
7814
|
+
t = JSON.parse(readFileSync34(join41(dir, "tweak.json"), "utf8"));
|
|
7532
7815
|
} catch {
|
|
7533
7816
|
return false;
|
|
7534
7817
|
}
|
|
7535
7818
|
if (t.deferred === void 0 || t.deferred === false) return false;
|
|
7536
7819
|
let judged;
|
|
7537
7820
|
try {
|
|
7538
|
-
judged = JSON.parse(
|
|
7821
|
+
judged = JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8")).tweak;
|
|
7539
7822
|
} catch {
|
|
7540
7823
|
}
|
|
7541
7824
|
return judged !== t.at;
|
|
@@ -7546,15 +7829,16 @@ function pageChecksum(html) {
|
|
|
7546
7829
|
function changedSinceLock(projectRoot, dir) {
|
|
7547
7830
|
let lock;
|
|
7548
7831
|
try {
|
|
7549
|
-
lock = JSON.parse(
|
|
7832
|
+
lock = JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8"));
|
|
7550
7833
|
} catch {
|
|
7551
7834
|
return void 0;
|
|
7552
7835
|
}
|
|
7553
7836
|
const file = lock.page?.file;
|
|
7554
7837
|
if (!file || !lock.page?.checksum) return void 0;
|
|
7555
7838
|
try {
|
|
7556
|
-
const html =
|
|
7557
|
-
|
|
7839
|
+
const html = readFileSync34(join41(projectRoot, file), "utf8");
|
|
7840
|
+
const seen = [pageChecksum(html), checksum(html)];
|
|
7841
|
+
return seen.includes(lock.page.checksum) ? void 0 : file;
|
|
7558
7842
|
} catch {
|
|
7559
7843
|
return void 0;
|
|
7560
7844
|
}
|
|
@@ -7564,7 +7848,7 @@ function supersededBy(front) {
|
|
|
7564
7848
|
return named ? named : void 0;
|
|
7565
7849
|
}
|
|
7566
7850
|
function pageStatus(projectRoot, surface) {
|
|
7567
|
-
const spec =
|
|
7851
|
+
const spec = readText(join41(projectRoot, ".jig", "specs", `${surface}.spec.md`));
|
|
7568
7852
|
const front = spec.split(/^---\s*$/m)[1] ?? "";
|
|
7569
7853
|
if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) {
|
|
7570
7854
|
return { surface, state: "in-progress", detail: "its spec is not confirmed, so nothing of it is built to ship" };
|
|
@@ -7572,10 +7856,10 @@ function pageStatus(projectRoot, surface) {
|
|
|
7572
7856
|
const successor = supersededBy(front);
|
|
7573
7857
|
if (successor !== void 0) {
|
|
7574
7858
|
const file = successor.replace(/(\.spec\.md)?$/, ".spec.md");
|
|
7575
|
-
return
|
|
7859
|
+
return existsSync25(join41(projectRoot, ".jig", "specs", file)) ? { surface, state: "superseded", detail: `superseded by ${file}, whose critique judges its page` } : { surface, state: "incomplete", detail: `\`superseded_by: ${successor}\` names a spec that does not exist in .jig/specs` };
|
|
7576
7860
|
}
|
|
7577
|
-
const dir =
|
|
7578
|
-
if (!
|
|
7861
|
+
const dir = join41(projectRoot, ".jig", "critique", surface);
|
|
7862
|
+
if (!existsSync25(join41(dir, "screen.json")) && !existsSync25(join41(dir, "code.json"))) {
|
|
7579
7863
|
return { surface, state: "never", detail: "never critiqued" };
|
|
7580
7864
|
}
|
|
7581
7865
|
if (tweakDeferred(dir)) return { surface, state: "deferred", detail: "a tweak deferred its re-judge" };
|
|
@@ -7585,13 +7869,17 @@ function pageStatus(projectRoot, surface) {
|
|
|
7585
7869
|
const stale = v.errors.find((e) => /was taken (on|by) an older/.test(e));
|
|
7586
7870
|
let judgedPage = false;
|
|
7587
7871
|
try {
|
|
7588
|
-
judgedPage = !!JSON.parse(
|
|
7872
|
+
judgedPage = !!JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8")).page?.checksum;
|
|
7589
7873
|
} catch {
|
|
7590
7874
|
}
|
|
7591
7875
|
if (stale && !judgedPage) return { surface, state: "changed", detail: "the page changed after its probes were taken" };
|
|
7592
7876
|
if (stale) return { surface, state: "reprobe", detail: `its page is as judged, but the styles or scripts it loads changed: re-probe it (\`jig verdicts ${surface} --reprobe\`)` };
|
|
7593
7877
|
if (!v.ok) return { surface, state: "incomplete", detail: `its critique is not complete: ${v.errors[0]}` };
|
|
7594
7878
|
const findings = v.screen.findings + v.code.findings + v.decisions.findings;
|
|
7879
|
+
if (v.measured.length) {
|
|
7880
|
+
const also = findings > 0 ? `, and ${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` : "";
|
|
7881
|
+
return { surface, state: "findings", detail: `the page fails ${v.measured.length} check${v.measured.length === 1 ? "" : "s"} the render probe measured (\`jig verdicts ${surface}\` lists them)${also}` };
|
|
7882
|
+
}
|
|
7595
7883
|
if (findings > 0) return { surface, state: "findings", detail: `${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` };
|
|
7596
7884
|
return { surface, state: "judged", detail: "judged as it stands, nothing open" };
|
|
7597
7885
|
}
|
|
@@ -7601,17 +7889,19 @@ function ship(opts) {
|
|
|
7601
7889
|
const mechanicalErrors = mechanical.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
|
|
7602
7890
|
const seoResult = seo({ projectRoot: root });
|
|
7603
7891
|
const seoErrors = seoResult.findings.filter((f) => f.severity === "error").length;
|
|
7604
|
-
const specsDir =
|
|
7605
|
-
const surfaces =
|
|
7892
|
+
const specsDir = join41(root, ".jig", "specs");
|
|
7893
|
+
const surfaces = existsSync25(specsDir) ? readdirSync15(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
|
|
7606
7894
|
const pages = surfaces.map((s) => pageStatus(root, s));
|
|
7607
7895
|
const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress" && p.state !== "superseded");
|
|
7608
|
-
const
|
|
7896
|
+
const agentFiles = agentFileProblems(root);
|
|
7897
|
+
const ready = mechanicalErrors === 0 && seoErrors === 0 && owed.length === 0 && agentFiles.length === 0;
|
|
7609
7898
|
const count = (state) => pages.filter((p) => p.state === state).length;
|
|
7610
7899
|
const line = `JIG_SHIP: ready=${ready ? "yes" : "no"} mechanical=${mechanicalErrors} seo=${seoErrors} pages=${pages.length} judged=${count("judged")} owed=${owed.length} in-progress=${count("in-progress")} superseded=${count("superseded")}`;
|
|
7611
7900
|
const mark = { judged: "\u2713", never: "\u2717", changed: "\u2717", reprobe: "\u2717", deferred: "\u2717", incomplete: "\u2717", findings: "\u2717", "in-progress": "\xB7", superseded: "\xB7" };
|
|
7612
7901
|
const report2 = [
|
|
7613
7902
|
` ${mechanicalErrors === 0 ? "\u2713" : "\u2717"} check --all --ci: ${mechanicalErrors} mechanical error${mechanicalErrors === 1 ? "" : "s"}`,
|
|
7614
7903
|
` ${seoErrors === 0 ? "\u2713" : "\u2717"} seo: ${seoErrors} error${seoErrors === 1 ? "" : "s"}`,
|
|
7904
|
+
...agentFiles.map((p) => ` \u2717 ${p}`),
|
|
7615
7905
|
...pages.map((p) => ` ${mark[p.state]} ${p.surface}: ${p.detail}`),
|
|
7616
7906
|
owed.length ? ` Critique each page marked \u2717 (\`/jig critique <page>\`); fix what it finds, or record the owner's ruling, and run \`jig ship\` again.` : "",
|
|
7617
7907
|
` Not checked by Jig, here or anywhere: ${NOT_COVERED}.`,
|
|
@@ -7621,13 +7911,47 @@ function ship(opts) {
|
|
|
7621
7911
|
}
|
|
7622
7912
|
|
|
7623
7913
|
// src/check/spec-checked.ts
|
|
7624
|
-
import { existsSync as
|
|
7914
|
+
import { existsSync as existsSync26, readFileSync as readFileSync35 } from "fs";
|
|
7625
7915
|
import { createHash as createHash2 } from "crypto";
|
|
7626
|
-
import { join as
|
|
7916
|
+
import { join as join42 } from "path";
|
|
7917
|
+
|
|
7918
|
+
// src/check/owner-word.ts
|
|
7919
|
+
var SAYS = {
|
|
7920
|
+
confirm: /\b(confirm(s|ed)?|yes|yep|approve[ds]?|looks (good|right|fine)|lgtm|go ahead|agreed|that['’]?s right|correct|ok(ay)?)\b|^\s*go\b/im,
|
|
7921
|
+
approve: /\b(approve[ds]?|looks (good|right|fine)|lgtm|yes|go ahead|good to go|confirm(s|ed)?|agreed|build it|that['’]?s (it|right)|ok(ay)?)\b/i,
|
|
7922
|
+
skip: /\bskip|\bno (mockup|drawing)|without (a )?(mockup|drawing)|(don['’]?t|do not|no need to) (draw|mock)|(mockup|drawing) (isn['’]?t|is not) needed/i,
|
|
7923
|
+
defer: /\b(later|at ship|defer(red)?|wait|leave (it|the critique|the re-?judge)|(don['’]?t|do not) (re-?judge|critique)|no (re-?judge|critique)|skip the (re-?judge|critique))\b/i
|
|
7924
|
+
};
|
|
7925
|
+
function saysSo(word, text) {
|
|
7926
|
+
return SAYS[word].test(text);
|
|
7927
|
+
}
|
|
7928
|
+
var WHAT = {
|
|
7929
|
+
confirm: "confirm the spec",
|
|
7930
|
+
approve: "approve the drawing",
|
|
7931
|
+
skip: "skip the mockup",
|
|
7932
|
+
defer: "leave the re-judge for later"
|
|
7933
|
+
};
|
|
7934
|
+
function ownerWordProblem(word, quoted, owner, where) {
|
|
7935
|
+
const q = quoted.replace(/^["“]|["”]$/g, "").trim();
|
|
7936
|
+
if (!q) return `${where} records the owner's word to ${WHAT[word]} without their words. Quote what they said.`;
|
|
7937
|
+
if (!quoteHeld(q, owner)) return `${where} quotes "${q}", which the owner did not say in this session. Quote their reply as they wrote it; a word nobody gave is not one.`;
|
|
7938
|
+
if (!saysSo(word, q)) return `${where} quotes "${q}", which does not ${WHAT[word]}. Words the owner said about something else are not their word on this: ask them, and quote the answer.`;
|
|
7939
|
+
return void 0;
|
|
7940
|
+
}
|
|
7941
|
+
|
|
7942
|
+
// src/check/spec-checked.ts
|
|
7627
7943
|
function specChecksum(body) {
|
|
7628
|
-
return createHash2("sha256").update(body.replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
|
|
7944
|
+
return createHash2("sha256").update(body.replace(/\r\n/g, "\n").replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
|
|
7629
7945
|
}
|
|
7630
|
-
|
|
7946
|
+
function mockupField(body) {
|
|
7947
|
+
const raw = /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
|
|
7948
|
+
const double = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(raw);
|
|
7949
|
+
if (double) return double[1].replace(/\\(.)/g, "$1").trim();
|
|
7950
|
+
const single = /^'((?:[^']|'')*)'\s*(?:#.*)?$/.exec(raw);
|
|
7951
|
+
if (single) return single[1].replace(/''/g, "'").trim();
|
|
7952
|
+
return raw;
|
|
7953
|
+
}
|
|
7954
|
+
var mockupLine = mockupField;
|
|
7631
7955
|
function mockupWordProblems(spec, then, owner) {
|
|
7632
7956
|
const now = mockupLine(spec.body);
|
|
7633
7957
|
if (now === mockupLine(then)) return [];
|
|
@@ -7638,10 +7962,8 @@ function mockupWordProblems(spec, then, owner) {
|
|
|
7638
7962
|
if (!quoted) {
|
|
7639
7963
|
return [`${spec.path}: \`mockup: ${word}\` is recorded without the owner's words. Write \`mockup: ${word} \u2014 "<what they said>"\`, quoting the reply that ${word === "approved" ? "approved it" : "said to skip it"}.`];
|
|
7640
7964
|
}
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
}
|
|
7644
|
-
return [];
|
|
7965
|
+
const problem = ownerWordProblem(word === "approved" ? "approve" : "skip", quoted, owner, `${spec.path}: \`mockup: ${word}\``);
|
|
7966
|
+
return problem ? [problem] : [];
|
|
7645
7967
|
}
|
|
7646
7968
|
function mockupPending(body) {
|
|
7647
7969
|
const now = mockupLine(body);
|
|
@@ -7676,7 +7998,7 @@ function specCheckProblems(root, spec, then, owner, decisions) {
|
|
|
7676
7998
|
const recordPath = `.jig/specs/${spec.slug}.checked.json`;
|
|
7677
7999
|
let record;
|
|
7678
8000
|
try {
|
|
7679
|
-
record = JSON.parse(
|
|
8001
|
+
record = JSON.parse(readFileSync35(join42(root, recordPath), "utf8"));
|
|
7680
8002
|
} catch {
|
|
7681
8003
|
problems.push(
|
|
7682
8004
|
`${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.`
|
|
@@ -7705,16 +8027,16 @@ function specCheckProblems(root, spec, then, owner, decisions) {
|
|
|
7705
8027
|
if (unsaid.length) problems.push(`${recordPath}: ${unsaid.length} unchecked ${unsaid.length === 1 ? "fact says" : "facts say"} nothing of why. Say in \`note\` why the source could not be opened; the owner opens it instead.`);
|
|
7706
8028
|
const missing = facts.map((f) => typeof f?.source === "string" ? f.source.trim() : "").filter((s) => s && !/^[a-z][a-z0-9+.-]*:\/\//i.test(s)).filter((s) => {
|
|
7707
8029
|
const [, file, line] = /^(.+?)(?::(\d+)(?:-\d+)?)?$/.exec(s) ?? [];
|
|
7708
|
-
if (!file || !
|
|
8030
|
+
if (!file || !existsSync26(join42(root, file))) return true;
|
|
7709
8031
|
if (!line) return false;
|
|
7710
8032
|
try {
|
|
7711
|
-
return Number(line) >
|
|
8033
|
+
return Number(line) > readFileSync35(join42(root, file), "utf8").split("\n").length;
|
|
7712
8034
|
} catch {
|
|
7713
8035
|
return true;
|
|
7714
8036
|
}
|
|
7715
8037
|
});
|
|
7716
8038
|
if (missing.length) {
|
|
7717
|
-
problems.push(`${recordPath} cites ${missing.length === 1 ? "a source" : "sources"} the project does not have: ${missing.slice(0, 4).join(", ")}. A fact is checked at a source that exists
|
|
8039
|
+
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: \`source\` is a path from the project root, \`path:line\`, or a URL, and nothing else; what you found there goes in \`note\`.`);
|
|
7718
8040
|
}
|
|
7719
8041
|
return problems;
|
|
7720
8042
|
}
|
|
@@ -7722,10 +8044,10 @@ function specCheckProblems(root, spec, then, owner, decisions) {
|
|
|
7722
8044
|
// src/commands/gate.ts
|
|
7723
8045
|
var MAX_BLOCKS = 3;
|
|
7724
8046
|
function lastJigInvocation(transcriptPath) {
|
|
7725
|
-
if (!transcriptPath || !
|
|
8047
|
+
if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
|
|
7726
8048
|
let text;
|
|
7727
8049
|
try {
|
|
7728
|
-
text =
|
|
8050
|
+
text = readFileSync36(transcriptPath, "utf8");
|
|
7729
8051
|
} catch {
|
|
7730
8052
|
return void 0;
|
|
7731
8053
|
}
|
|
@@ -7750,10 +8072,10 @@ function lastJigInvocation(transcriptPath) {
|
|
|
7750
8072
|
return found;
|
|
7751
8073
|
}
|
|
7752
8074
|
function ownerWords(transcriptPath) {
|
|
7753
|
-
if (!transcriptPath || !
|
|
8075
|
+
if (!transcriptPath || !existsSync27(transcriptPath)) return "";
|
|
7754
8076
|
const said = [];
|
|
7755
8077
|
try {
|
|
7756
|
-
for (const line of
|
|
8078
|
+
for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
|
|
7757
8079
|
if (!line.includes('"user"')) continue;
|
|
7758
8080
|
let entry;
|
|
7759
8081
|
try {
|
|
@@ -7773,11 +8095,46 @@ function ownerWords(transcriptPath) {
|
|
|
7773
8095
|
}
|
|
7774
8096
|
return said.join("\n");
|
|
7775
8097
|
}
|
|
8098
|
+
function namedSpec(root, surface) {
|
|
8099
|
+
if (!surface) return specFor(root, void 0);
|
|
8100
|
+
return existsSync27(join43(root, ".jig", "specs", `${surface}.spec.md`)) ? specFor(root, surface) : void 0;
|
|
8101
|
+
}
|
|
8102
|
+
function isConfirmed(body) {
|
|
8103
|
+
return /^\s*confirmed\s*:\s*true\b/im.test(body.split(/^---\s*$/m)[1] ?? "");
|
|
8104
|
+
}
|
|
8105
|
+
function ownerReplyAfter(transcriptPath, asked) {
|
|
8106
|
+
if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
|
|
8107
|
+
const turns = [];
|
|
8108
|
+
try {
|
|
8109
|
+
for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
|
|
8110
|
+
let entry;
|
|
8111
|
+
try {
|
|
8112
|
+
entry = JSON.parse(line);
|
|
8113
|
+
} catch {
|
|
8114
|
+
continue;
|
|
8115
|
+
}
|
|
8116
|
+
if (entry.type !== "user" && entry.type !== "assistant" || entry.isMeta) continue;
|
|
8117
|
+
const content = entry.message?.content;
|
|
8118
|
+
const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
|
|
8119
|
+
for (const text of texts) turns.push({ who: entry.type === "user" ? "owner" : "agent", text });
|
|
8120
|
+
}
|
|
8121
|
+
} catch {
|
|
8122
|
+
return void 0;
|
|
8123
|
+
}
|
|
8124
|
+
let answer;
|
|
8125
|
+
turns.forEach((t, i) => {
|
|
8126
|
+
if (t.who !== "agent" || !asked.test(t.text)) return;
|
|
8127
|
+
const reply = [];
|
|
8128
|
+
for (let j = i + 1; j < turns.length && turns[j].who === "owner"; j++) reply.push(turns[j].text);
|
|
8129
|
+
if (reply.length) answer = reply.join("\n");
|
|
8130
|
+
});
|
|
8131
|
+
return answer;
|
|
8132
|
+
}
|
|
7776
8133
|
function lastAssistantText(transcriptPath) {
|
|
7777
|
-
if (!transcriptPath || !
|
|
8134
|
+
if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
|
|
7778
8135
|
let found;
|
|
7779
8136
|
try {
|
|
7780
|
-
for (const line of
|
|
8137
|
+
for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
|
|
7781
8138
|
if (!line.includes('"assistant"')) continue;
|
|
7782
8139
|
let entry;
|
|
7783
8140
|
try {
|
|
@@ -7801,9 +8158,9 @@ function asksOwner(text) {
|
|
|
7801
8158
|
}
|
|
7802
8159
|
var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
|
|
7803
8160
|
function sessionStart(transcriptPath) {
|
|
7804
|
-
if (!transcriptPath || !
|
|
8161
|
+
if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
|
|
7805
8162
|
try {
|
|
7806
|
-
for (const line of
|
|
8163
|
+
for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
|
|
7807
8164
|
const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
|
|
7808
8165
|
if (m) {
|
|
7809
8166
|
const t = Date.parse(m[1]);
|
|
@@ -7818,20 +8175,20 @@ function verdictsMtime(dir) {
|
|
|
7818
8175
|
let newest = 0;
|
|
7819
8176
|
for (const name of VERDICT_FILES) {
|
|
7820
8177
|
try {
|
|
7821
|
-
newest = Math.max(newest, statSync5(
|
|
8178
|
+
newest = Math.max(newest, statSync5(join43(dir, name)).mtimeMs);
|
|
7822
8179
|
} catch {
|
|
7823
8180
|
}
|
|
7824
8181
|
}
|
|
7825
8182
|
return newest;
|
|
7826
8183
|
}
|
|
7827
8184
|
function surfacesInPlay(root, command, transcriptPath, surface) {
|
|
7828
|
-
const critiqueDir =
|
|
7829
|
-
if (!
|
|
8185
|
+
const critiqueDir = join43(root, ".jig", "critique");
|
|
8186
|
+
if (!existsSync27(critiqueDir)) return [];
|
|
7830
8187
|
const all = readdirSync16(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
7831
8188
|
const start = sessionStart(transcriptPath);
|
|
7832
8189
|
if (start === void 0) return all;
|
|
7833
8190
|
const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
|
|
7834
|
-
return all.filter((s) => s === current || verdictsMtime(
|
|
8191
|
+
return all.filter((s) => s === current || verdictsMtime(join43(critiqueDir, s)) >= start - 1e3);
|
|
7835
8192
|
}
|
|
7836
8193
|
function surfacesToProbe(root, input) {
|
|
7837
8194
|
const invocation = lastJigInvocation(input.transcript_path);
|
|
@@ -7839,9 +8196,13 @@ function surfacesToProbe(root, input) {
|
|
|
7839
8196
|
}
|
|
7840
8197
|
function fileAtSessionStart(root, path, start) {
|
|
7841
8198
|
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
8199
|
+
try {
|
|
8200
|
+
if (start !== void 0 && statSync5(join43(root, path)).mtimeMs < start - 1e3) return readText(join43(root, path));
|
|
8201
|
+
} catch {
|
|
8202
|
+
}
|
|
7842
8203
|
try {
|
|
7843
8204
|
const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
|
|
7844
|
-
return base ? git(["show", `${base}:./${path}`]) : "";
|
|
8205
|
+
return base ? lf(git(["show", `${base}:./${path}`])) : "";
|
|
7845
8206
|
} catch {
|
|
7846
8207
|
return "";
|
|
7847
8208
|
}
|
|
@@ -7854,7 +8215,7 @@ function changedOutsideRecords(root, start) {
|
|
|
7854
8215
|
const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
|
|
7855
8216
|
const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
|
|
7856
8217
|
try {
|
|
7857
|
-
return start === void 0 || statSync5(
|
|
8218
|
+
return start === void 0 || statSync5(join43(root, f)).mtimeMs >= start - 1e3;
|
|
7858
8219
|
} catch {
|
|
7859
8220
|
return false;
|
|
7860
8221
|
}
|
|
@@ -7869,8 +8230,8 @@ function drawingChangedAfterApproval(root, specPath, at) {
|
|
|
7869
8230
|
try {
|
|
7870
8231
|
const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
|
|
7871
8232
|
if (!approvedIn) return void 0;
|
|
7872
|
-
const then = git(["show", `${approvedIn}:./${at}`]);
|
|
7873
|
-
return then !==
|
|
8233
|
+
const then = lf(git(["show", `${approvedIn}:./${at}`]));
|
|
8234
|
+
return then !== readText(join43(root, at)) ? approvedIn.slice(0, 7) : void 0;
|
|
7874
8235
|
} catch {
|
|
7875
8236
|
return void 0;
|
|
7876
8237
|
}
|
|
@@ -7882,18 +8243,25 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
7882
8243
|
const found = decisionsFile(root);
|
|
7883
8244
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
7884
8245
|
else {
|
|
7885
|
-
const body =
|
|
8246
|
+
const body = readText(join43(root, found));
|
|
7886
8247
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
7887
8248
|
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.`");
|
|
7888
8249
|
}
|
|
7889
8250
|
if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
|
|
7890
|
-
|
|
8251
|
+
const then = fileAtSessionStart(root, found, start);
|
|
8252
|
+
problems.push(...unsourcedReasons(body, then, { newWhysOnly: true }).map((p) => `DECISIONS.md: ${p}`));
|
|
8253
|
+
if (owner) problems.push(...quotesNotFrom(body, then, owner, "the owner's words in this session").map((p) => `DECISIONS.md: ${p}`));
|
|
7891
8254
|
}
|
|
7892
8255
|
}
|
|
8256
|
+
if (command === "spec" && surface && !existsSync27(join43(root, ".jig", "specs", `${surface}.spec.md`))) {
|
|
8257
|
+
problems.push(`spec wrote no .jig/specs/${surface}.spec.md. The spec is that file: a draft anywhere else is read by no later step and checked by nothing.`);
|
|
8258
|
+
return problems;
|
|
8259
|
+
}
|
|
7893
8260
|
if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
|
|
7894
8261
|
if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
|
|
7895
8262
|
else {
|
|
7896
8263
|
problems.push(...specProblems(spec));
|
|
8264
|
+
problems.push(...newFieldProblems(spec, fileAtSessionStart(root, spec.path, start)));
|
|
7897
8265
|
if (problems.length === 0) problems.push(...navProblems(spec));
|
|
7898
8266
|
}
|
|
7899
8267
|
}
|
|
@@ -7903,20 +8271,20 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
7903
8271
|
}
|
|
7904
8272
|
if (command === "mockup" && spec) {
|
|
7905
8273
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7906
|
-
const mockup =
|
|
8274
|
+
const mockup = mockupField(spec.body);
|
|
7907
8275
|
if (/^pending/i.test(mockup) || !mockup) problems.push(`${spec.path}: \`mockup:\` is still pending. It records the user's own word \u2014 approved, or skipped with their reason.`);
|
|
7908
8276
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7909
8277
|
if (/^approved/i.test(mockup)) {
|
|
7910
8278
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
7911
|
-
else if (!/^https?:/i.test(at) && !
|
|
8279
|
+
else if (!/^https?:/i.test(at) && !existsSync27(join43(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
7912
8280
|
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.`);
|
|
7913
8281
|
}
|
|
7914
|
-
if (at && /\.html?$/i.test(at) &&
|
|
8282
|
+
if (at && /\.html?$/i.test(at) && existsSync27(join43(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
|
|
7915
8283
|
}
|
|
7916
|
-
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
|
|
8284
|
+
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner, start));
|
|
7917
8285
|
if ((command === "spec" || command === "make") && spec) {
|
|
7918
8286
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7919
|
-
const mockup =
|
|
8287
|
+
const mockup = mockupField(spec.body);
|
|
7920
8288
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7921
8289
|
if (/^approved/i.test(mockup) && /\.html?$/i.test(at) && !/^https?:/i.test(at)) {
|
|
7922
8290
|
const drift = approvedDrawingProblems(root, spec.body, at);
|
|
@@ -7937,9 +8305,9 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
7937
8305
|
}
|
|
7938
8306
|
if (command === "mockup" && spec) {
|
|
7939
8307
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7940
|
-
const mockup =
|
|
8308
|
+
const mockup = mockupField(spec.body);
|
|
7941
8309
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7942
|
-
if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) &&
|
|
8310
|
+
if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync27(join43(root, at))) {
|
|
7943
8311
|
const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
|
|
7944
8312
|
if (changedAfter) {
|
|
7945
8313
|
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.`);
|
|
@@ -7951,7 +8319,7 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
7951
8319
|
if (found) {
|
|
7952
8320
|
let now = "";
|
|
7953
8321
|
try {
|
|
7954
|
-
now =
|
|
8322
|
+
now = readText(join43(root, found));
|
|
7955
8323
|
} catch {
|
|
7956
8324
|
}
|
|
7957
8325
|
const then = fileAtSessionStart(root, found, start);
|
|
@@ -7965,13 +8333,13 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
7965
8333
|
if (found) {
|
|
7966
8334
|
let now = "";
|
|
7967
8335
|
try {
|
|
7968
|
-
now =
|
|
8336
|
+
now = readText(join43(root, found));
|
|
7969
8337
|
} catch {
|
|
7970
8338
|
}
|
|
7971
8339
|
const then = fileAtSessionStart(root, found, start);
|
|
7972
8340
|
if (now && now !== then) {
|
|
7973
8341
|
problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
|
|
7974
|
-
const change = readTweak(
|
|
8342
|
+
const change = readTweak(join43(root, ".jig", "critique", spec.slug))?.change;
|
|
7975
8343
|
if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
|
|
7976
8344
|
}
|
|
7977
8345
|
}
|
|
@@ -7980,7 +8348,7 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
7980
8348
|
for (const file of modeFiles(root)) {
|
|
7981
8349
|
let now = "";
|
|
7982
8350
|
try {
|
|
7983
|
-
now =
|
|
8351
|
+
now = readText(join43(root, file));
|
|
7984
8352
|
} catch {
|
|
7985
8353
|
continue;
|
|
7986
8354
|
}
|
|
@@ -7991,8 +8359,8 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
7991
8359
|
}
|
|
7992
8360
|
}
|
|
7993
8361
|
if (command === "critique") {
|
|
7994
|
-
const dir =
|
|
7995
|
-
const surfaces =
|
|
8362
|
+
const dir = join43(root, ".jig", "critique");
|
|
8363
|
+
const surfaces = existsSync27(dir) ? readdirSync16(dir).filter((s) => existsSync27(join43(dir, s, "screen.json")) || existsSync27(join43(dir, s, "code.json"))) : [];
|
|
7996
8364
|
for (const surface2 of surfaces) {
|
|
7997
8365
|
const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
|
|
7998
8366
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -8003,7 +8371,7 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
8003
8371
|
}
|
|
8004
8372
|
}
|
|
8005
8373
|
const own = specFor(root, surface)?.slug;
|
|
8006
|
-
if (own && surfaces.includes(own)) problems.push(...reportOmissions(
|
|
8374
|
+
if (own && surfaces.includes(own)) problems.push(...reportOmissions(join43(dir, own), own));
|
|
8007
8375
|
if (surfaces.length === 0) {
|
|
8008
8376
|
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.");
|
|
8009
8377
|
}
|
|
@@ -8016,7 +8384,7 @@ function drawingBeforeAsking(root, surface) {
|
|
|
8016
8384
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
8017
8385
|
const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
8018
8386
|
const at = named || `.jig/mockups/${spec.slug}.html`;
|
|
8019
|
-
if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !
|
|
8387
|
+
if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync27(join43(root, at))) return [];
|
|
8020
8388
|
const found = mockupDrawingProblems(root, spec.body, at);
|
|
8021
8389
|
return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
|
|
8022
8390
|
}
|
|
@@ -8024,14 +8392,14 @@ function specChecked(root, spec, start, owner) {
|
|
|
8024
8392
|
const found = decisionsFile(root);
|
|
8025
8393
|
let decisions = "";
|
|
8026
8394
|
try {
|
|
8027
|
-
if (found) decisions =
|
|
8395
|
+
if (found) decisions = readText(join43(root, found));
|
|
8028
8396
|
} catch {
|
|
8029
8397
|
}
|
|
8030
8398
|
return specCheckProblems(root, spec, fileAtSessionStart(root, spec.path, start), owner, decisions);
|
|
8031
8399
|
}
|
|
8032
8400
|
function modeFiles(root) {
|
|
8033
8401
|
try {
|
|
8034
|
-
const state = JSON.parse(
|
|
8402
|
+
const state = JSON.parse(readFileSync36(join43(root, ".jig", "state.json"), "utf8"));
|
|
8035
8403
|
return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
|
|
8036
8404
|
} catch {
|
|
8037
8405
|
return [];
|
|
@@ -8040,7 +8408,7 @@ function modeFiles(root) {
|
|
|
8040
8408
|
function reportOmissions(dir, surface) {
|
|
8041
8409
|
let report2;
|
|
8042
8410
|
try {
|
|
8043
|
-
report2 =
|
|
8411
|
+
report2 = readText(join43(dir, "REPORT.md")).toLowerCase();
|
|
8044
8412
|
} catch {
|
|
8045
8413
|
return [];
|
|
8046
8414
|
}
|
|
@@ -8048,7 +8416,7 @@ function reportOmissions(dir, surface) {
|
|
|
8048
8416
|
for (const f of VERDICT_FILES) {
|
|
8049
8417
|
let file;
|
|
8050
8418
|
try {
|
|
8051
|
-
file = JSON.parse(
|
|
8419
|
+
file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
|
|
8052
8420
|
} catch {
|
|
8053
8421
|
continue;
|
|
8054
8422
|
}
|
|
@@ -8061,29 +8429,39 @@ function reportOmissions(dir, surface) {
|
|
|
8061
8429
|
if (!missing.length) return [];
|
|
8062
8430
|
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).`];
|
|
8063
8431
|
}
|
|
8064
|
-
function tweakProblems(root, spec, owner = "") {
|
|
8432
|
+
function tweakProblems(root, spec, owner = "", start) {
|
|
8065
8433
|
const problems = [];
|
|
8066
8434
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
8067
8435
|
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\`.`);
|
|
8068
|
-
const mockup =
|
|
8069
|
-
if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
|
|
8436
|
+
const mockup = mockupField(spec.body);
|
|
8437
|
+
if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup.split(/\s/)[0] || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
|
|
8070
8438
|
else problems.push(...structureSinceApproval(root, spec, front));
|
|
8071
8439
|
const surface = spec.slug;
|
|
8072
|
-
const record = readTweak(
|
|
8440
|
+
const record = readTweak(join43(root, ".jig", "critique", surface));
|
|
8073
8441
|
if (!record) {
|
|
8074
8442
|
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\`).`);
|
|
8075
8443
|
} else {
|
|
8076
8444
|
if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
|
|
8445
|
+
else if (owner && fileAtSessionStart(root, `.jig/critique/${surface}/tweak.json`, start) !== readText(join43(root, ".jig", "critique", surface, "tweak.json")) && !quoteHeld(record.change.replace(/^["“]|["”]$/g, ""), owner)) {
|
|
8446
|
+
problems.push(`.jig/critique/${surface}/tweak.json: \`change\` is "${record.change.length > 90 ? `${record.change.slice(0, 90)}\u2026` : record.change}", which the owner did not say in this session. It is their words for the change, as they gave them; what you made of them goes in the spec's Tweak entry.`);
|
|
8447
|
+
}
|
|
8448
|
+
try {
|
|
8449
|
+
const raw = JSON.parse(readFileSync36(join43(root, ".jig", "critique", surface, "tweak.json"), "utf8"));
|
|
8450
|
+
const extra = Object.keys(raw).filter((k) => !["at", "change", "ids", "deferred"].includes(k));
|
|
8451
|
+
if (extra.length) problems.push(`.jig/critique/${surface}/tweak.json: ${extra.map((k) => `\`${k}\``).join(", ")} ${extra.length === 1 ? "is not a field" : "are not fields"} Jig reads. tweak.json holds \`at\`, \`change\`, \`ids\` and \`deferred\`; what else you have to say goes in the spec's Tweak entry.`);
|
|
8452
|
+
} catch {
|
|
8453
|
+
}
|
|
8077
8454
|
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.`);
|
|
8078
8455
|
if (record.deferred !== void 0) {
|
|
8079
|
-
|
|
8456
|
+
const said = typeof record.deferred === "string" ? ownerWordProblem("defer", record.deferred, owner, `.jig/critique/${surface}/tweak.json: \`deferred\``) : void 0;
|
|
8457
|
+
if (typeof record.deferred === "string" ? said !== void 0 : !critiqueAtShip(root, spec.body)) {
|
|
8080
8458
|
problems.push(
|
|
8081
|
-
`.jig/critique/${surface}/tweak.json defers its re-judge, and nobody said to. \`deferred\` quotes the owner telling you to leave it for later, or is \`true\` where the page's spec says \`critique: at-ship\` (or, the spec silent, jig.config.json says \`"critique": "at-ship"\`). Otherwise re-judge what it names.`
|
|
8459
|
+
`.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.` + (said ? ` ${said}` : "")
|
|
8082
8460
|
);
|
|
8083
8461
|
}
|
|
8084
8462
|
return problems;
|
|
8085
8463
|
}
|
|
8086
|
-
const unjudged = unjudgedTweakIds(
|
|
8464
|
+
const unjudged = unjudgedTweakIds(join43(root, ".jig", "critique", surface), record);
|
|
8087
8465
|
if (unjudged.length) {
|
|
8088
8466
|
problems.push(
|
|
8089
8467
|
`.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.`
|
|
@@ -8110,7 +8488,7 @@ function structureSinceApproval(root, spec, front) {
|
|
|
8110
8488
|
const problems = [];
|
|
8111
8489
|
let thenBody = "";
|
|
8112
8490
|
try {
|
|
8113
|
-
thenBody = git(["show", `${baseline}:./${spec.path}`]);
|
|
8491
|
+
thenBody = lf(git(["show", `${baseline}:./${spec.path}`]));
|
|
8114
8492
|
} catch {
|
|
8115
8493
|
return [];
|
|
8116
8494
|
}
|
|
@@ -8119,9 +8497,9 @@ function structureSinceApproval(root, spec, front) {
|
|
|
8119
8497
|
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\`.`);
|
|
8120
8498
|
}
|
|
8121
8499
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
8122
|
-
if (at && !/^https?:/i.test(at) &&
|
|
8500
|
+
if (at && !/^https?:/i.test(at) && existsSync27(join43(root, at))) {
|
|
8123
8501
|
try {
|
|
8124
|
-
if (git(["show", `${baseline}:./${at}`]) !==
|
|
8502
|
+
if (lf(git(["show", `${baseline}:./${at}`])) !== readText(join43(root, at))) {
|
|
8125
8503
|
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\`.`);
|
|
8126
8504
|
}
|
|
8127
8505
|
} catch {
|
|
@@ -8131,7 +8509,7 @@ function structureSinceApproval(root, spec, front) {
|
|
|
8131
8509
|
}
|
|
8132
8510
|
function readTweak(dir) {
|
|
8133
8511
|
try {
|
|
8134
|
-
const raw = JSON.parse(
|
|
8512
|
+
const raw = JSON.parse(readFileSync36(join43(dir, "tweak.json"), "utf8"));
|
|
8135
8513
|
const deferred = typeof raw.deferred === "string" || raw.deferred === true ? raw.deferred : void 0;
|
|
8136
8514
|
return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [], ...deferred !== void 0 ? { deferred } : {} };
|
|
8137
8515
|
} catch {
|
|
@@ -8144,20 +8522,20 @@ function surfacePage(projectRoot, surface) {
|
|
|
8144
8522
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
8145
8523
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
8146
8524
|
for (const candidate of candidates) {
|
|
8147
|
-
if (candidate &&
|
|
8525
|
+
if (candidate && existsSync27(join43(projectRoot, candidate))) return candidate;
|
|
8148
8526
|
}
|
|
8149
8527
|
return void 0;
|
|
8150
8528
|
}
|
|
8151
8529
|
function gate(opts) {
|
|
8152
8530
|
const root = opts.projectRoot;
|
|
8153
|
-
if (!
|
|
8531
|
+
if (!existsSync27(join43(root, "jig.config.json")) && !existsSync27(join43(root, ".jig"))) {
|
|
8154
8532
|
return { block: false, reason: "" };
|
|
8155
8533
|
}
|
|
8156
8534
|
const invocation = lastJigInvocation(opts.input.transcript_path);
|
|
8157
8535
|
const command = invocation?.command;
|
|
8158
8536
|
const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
|
|
8159
8537
|
const start = sessionStart(opts.input.transcript_path);
|
|
8160
|
-
const owner = command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
|
|
8538
|
+
const owner = command === "decide" || command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
|
|
8161
8539
|
const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, start, owner).map((p) => `/jig ${command}: ${p}`) : [];
|
|
8162
8540
|
if (waiting && command === "mockup") problems.push(...drawingBeforeAsking(root, invocation?.surface).map((p) => `/jig mockup: ${p}`));
|
|
8163
8541
|
if (command === "ship" && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
|
|
@@ -8171,9 +8549,24 @@ ${result.report}`);
|
|
|
8171
8549
|
problems.push(`/jig make: ${spec.path} still says \`mockup: pending\`: nobody has said whether to draw it. Ask the owner, draw it with \`/jig mockup\` or skip it; if they skip it, write \`mockup: skipped \u2014 "<their words>"\` and build from the spec alone.`);
|
|
8172
8550
|
}
|
|
8173
8551
|
}
|
|
8552
|
+
if (command === "spec" && !waiting) {
|
|
8553
|
+
const spec = namedSpec(root, invocation?.surface);
|
|
8554
|
+
if (spec && isConfirmed(spec.body) && !isConfirmed(fileAtSessionStart(root, spec.path, start))) {
|
|
8555
|
+
const reply = ownerReplyAfter(opts.input.transcript_path, /\bconfirm/i);
|
|
8556
|
+
const said = reply === void 0 ? void 0 : ownerWordProblem("confirm", reply.slice(0, 400), reply, `${spec.path}: \`confirmed: true\``);
|
|
8557
|
+
if (reply === void 0) problems.push(`/jig spec: ${spec.path} is recorded as confirmed, and the owner was never asked to confirm it. Put it to them with its sheet and stop; record \`confirmed: true\` when they say yes.`);
|
|
8558
|
+
else if (said) problems.push(`/jig spec: ${spec.path} is recorded as confirmed, but the owner's reply to being asked does not confirm it ("${reply.trim().slice(0, 120)}"). Record \`confirmed: true\` only on their yes.`);
|
|
8559
|
+
}
|
|
8560
|
+
}
|
|
8174
8561
|
if (waiting && command === "spec") {
|
|
8175
|
-
const spec =
|
|
8562
|
+
const spec = namedSpec(root, invocation?.surface);
|
|
8563
|
+
if (!spec && invocation?.surface && /\bconfirm/i.test(lastAssistantText(opts.input.transcript_path) ?? "")) {
|
|
8564
|
+
problems.push(`/jig spec: you are asking the owner to confirm a spec that is not at .jig/specs/${invocation.surface}.spec.md. Write it there, with \`confirmed: false\`, have it checked (step 3c), and ask again: the checks the owner relies on read that file, and a draft kept anywhere else is checked by nothing.`);
|
|
8565
|
+
}
|
|
8176
8566
|
if (spec) problems.push(...specChecked(root, spec, start, owner).map((p) => `/jig spec: ${p}`));
|
|
8567
|
+
if (spec && existsSync27(join43(root, ".jig", "specs", `${spec.slug}.checked.json`))) {
|
|
8568
|
+
problems.push(...[...specProblems(spec), ...newFieldProblems(spec, fileAtSessionStart(root, spec.path, start))].map((p) => `/jig spec: ${p}`));
|
|
8569
|
+
}
|
|
8177
8570
|
}
|
|
8178
8571
|
const selection = selectFiles(root, false);
|
|
8179
8572
|
const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
|
|
@@ -8196,11 +8589,17 @@ ${result.report}`);
|
|
|
8196
8589
|
}
|
|
8197
8590
|
}
|
|
8198
8591
|
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
|
|
8199
|
-
|
|
8200
|
-
|
|
8592
|
+
problems.push(...probesLeftBehind(root, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
|
|
8593
|
+
const touched = ["AGENTS.md", "CLAUDE.md"].some((f) => {
|
|
8594
|
+
const now = existsSync27(join43(root, f)) ? readText(join43(root, f)) : "";
|
|
8595
|
+
return fileAtSessionStart(root, f, start) !== now;
|
|
8596
|
+
});
|
|
8597
|
+
if (touched) problems.push(...agentFileProblems(root).map((p) => `Jig's agent instructions: ${p}`));
|
|
8598
|
+
const critiqueDir = join43(root, ".jig", "critique");
|
|
8599
|
+
if (existsSync27(critiqueDir)) {
|
|
8201
8600
|
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
|
|
8202
|
-
const dir =
|
|
8203
|
-
if (!
|
|
8601
|
+
const dir = join43(critiqueDir, surface);
|
|
8602
|
+
if (!existsSync27(join43(dir, "screen.json")) && !existsSync27(join43(dir, "code.json"))) continue;
|
|
8204
8603
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
8205
8604
|
if (!v.ok) {
|
|
8206
8605
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -8209,6 +8608,13 @@ ${result.report}`);
|
|
|
8209
8608
|
` + shown.join("\n") + (v.errors.length > shown.length ? `
|
|
8210
8609
|
\u2026 and ${v.errors.length - shown.length} more` : "")
|
|
8211
8610
|
);
|
|
8611
|
+
} else if (v.measured.length && command !== "critique") {
|
|
8612
|
+
const shown = v.measured.slice(0, 6).map((e) => ` ${e}`);
|
|
8613
|
+
problems.push(
|
|
8614
|
+
`jig verdicts ${surface}: the page fails what the render probe measured. Fix the page, then re-take the probes (\`jig verdicts ${surface} --reprobe\`).
|
|
8615
|
+
` + shown.join("\n") + (v.measured.length > shown.length ? `
|
|
8616
|
+
\u2026 and ${v.measured.length - shown.length} more` : "")
|
|
8617
|
+
);
|
|
8212
8618
|
}
|
|
8213
8619
|
}
|
|
8214
8620
|
}
|
|
@@ -8223,10 +8629,10 @@ ${problems.join("\n\n")}`
|
|
|
8223
8629
|
}
|
|
8224
8630
|
const session = opts.input.session_id ?? "unknown";
|
|
8225
8631
|
const key = `${session}:${createHash3("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
8226
|
-
const stateFile =
|
|
8632
|
+
const stateFile = join43(root, ".jig", "gate.json");
|
|
8227
8633
|
let state = {};
|
|
8228
8634
|
try {
|
|
8229
|
-
state = JSON.parse(
|
|
8635
|
+
state = JSON.parse(readFileSync36(stateFile, "utf8"));
|
|
8230
8636
|
} catch {
|
|
8231
8637
|
}
|
|
8232
8638
|
if (problems.length === 0) {
|
|
@@ -8261,7 +8667,7 @@ function verdictDigests(dir) {
|
|
|
8261
8667
|
for (const f of VERDICT_FILES) {
|
|
8262
8668
|
let file;
|
|
8263
8669
|
try {
|
|
8264
|
-
file = JSON.parse(
|
|
8670
|
+
file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
|
|
8265
8671
|
} catch {
|
|
8266
8672
|
continue;
|
|
8267
8673
|
}
|
|
@@ -8276,7 +8682,7 @@ function writeLock(root, surface, lockPath, dir, now) {
|
|
|
8276
8682
|
const pageFile2 = recordedPage(root, surface);
|
|
8277
8683
|
let page;
|
|
8278
8684
|
try {
|
|
8279
|
-
if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(
|
|
8685
|
+
if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync36(join43(root, pageFile2), "utf8")) };
|
|
8280
8686
|
} catch {
|
|
8281
8687
|
}
|
|
8282
8688
|
const tweakAt = readTweak(dir)?.at;
|
|
@@ -8289,7 +8695,7 @@ function verdictChecksum(dir) {
|
|
|
8289
8695
|
const parts = VERDICT_FILES.map((f) => {
|
|
8290
8696
|
try {
|
|
8291
8697
|
return `${f}
|
|
8292
|
-
${
|
|
8698
|
+
${readFileSync36(join43(dir, f), "utf8")}`;
|
|
8293
8699
|
} catch {
|
|
8294
8700
|
return "";
|
|
8295
8701
|
}
|
|
@@ -8297,15 +8703,15 @@ ${readFileSync35(join42(dir, f), "utf8")}`;
|
|
|
8297
8703
|
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
8298
8704
|
}
|
|
8299
8705
|
function verdictGuard(root, command, inPlay) {
|
|
8300
|
-
const critiqueDir =
|
|
8301
|
-
if (!
|
|
8706
|
+
const critiqueDir = join43(root, ".jig", "critique");
|
|
8707
|
+
if (!existsSync27(critiqueDir)) return [];
|
|
8302
8708
|
const problems = [];
|
|
8303
8709
|
for (const surface of readdirSync16(critiqueDir)) {
|
|
8304
8710
|
if (surface.startsWith("_")) continue;
|
|
8305
|
-
const dir =
|
|
8711
|
+
const dir = join43(critiqueDir, surface);
|
|
8306
8712
|
const now = verdictChecksum(dir);
|
|
8307
8713
|
if (!now) continue;
|
|
8308
|
-
const lockPath =
|
|
8714
|
+
const lockPath = join43(dir, LOCK);
|
|
8309
8715
|
if ((command === "critique" || command === "ship") && (!inPlay || inPlay.includes(surface))) {
|
|
8310
8716
|
writeLock(root, surface, lockPath, dir, now);
|
|
8311
8717
|
problems.push(...lockLeftBehind(root, surface));
|
|
@@ -8313,7 +8719,7 @@ function verdictGuard(root, command, inPlay) {
|
|
|
8313
8719
|
}
|
|
8314
8720
|
let lock = {};
|
|
8315
8721
|
try {
|
|
8316
|
-
lock = JSON.parse(
|
|
8722
|
+
lock = JSON.parse(readFileSync36(lockPath, "utf8"));
|
|
8317
8723
|
} catch {
|
|
8318
8724
|
continue;
|
|
8319
8725
|
}
|
|
@@ -8346,6 +8752,25 @@ function lockLeftBehind(root, surface) {
|
|
|
8346
8752
|
}
|
|
8347
8753
|
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.`];
|
|
8348
8754
|
}
|
|
8755
|
+
function probesLeftBehind(root, surfaces) {
|
|
8756
|
+
if (!surfaces.length) return [];
|
|
8757
|
+
let lines;
|
|
8758
|
+
try {
|
|
8759
|
+
lines = execFileSync4("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n").filter(Boolean);
|
|
8760
|
+
} catch {
|
|
8761
|
+
return [];
|
|
8762
|
+
}
|
|
8763
|
+
const path = (l) => l.slice(3).replace(/^"|"$/g, "");
|
|
8764
|
+
const isProbe = (p) => /^\.jig\/critique\/[^/]+\/probe-[^/]+\.json$/.test(p);
|
|
8765
|
+
const isLock = (p) => /^\.jig\/critique\/[^/]+\/verdicts\.lock$/.test(p);
|
|
8766
|
+
if (lines.some((l) => !isProbe(path(l)) && !isLock(path(l)))) return [];
|
|
8767
|
+
const left = lines.map(path).filter((p) => isProbe(p) && surfaces.some((s) => p.startsWith(`.jig/critique/${s}/`)));
|
|
8768
|
+
if (!left.length) return [];
|
|
8769
|
+
const bySurface = [...new Set(left.map((p) => p.split("/")[2]))];
|
|
8770
|
+
return [
|
|
8771
|
+
`The gate re-took ${left.length} probe file${left.length === 1 ? "" : "s"} of ${bySurface.join(", ")} when you stopped, after your last commit: the page changed since they were taken. Commit them on their own (\`git add ${bySurface.map((s) => `.jig/critique/${s}/probe-*.json`).join(" ")} && git commit -m "chore: re-take ${bySurface.join(", ")}'s probes"\`), and nothing else.`
|
|
8772
|
+
];
|
|
8773
|
+
}
|
|
8349
8774
|
function tweakVerdictProblems(dir, surface, lockedVerdicts) {
|
|
8350
8775
|
const record = readTweak(dir);
|
|
8351
8776
|
if (!record) {
|
|
@@ -8370,7 +8795,7 @@ function unjudgedTweakIds(dir, record) {
|
|
|
8370
8795
|
for (const f of VERDICT_FILES) {
|
|
8371
8796
|
let file;
|
|
8372
8797
|
try {
|
|
8373
|
-
file = JSON.parse(
|
|
8798
|
+
file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
|
|
8374
8799
|
} catch {
|
|
8375
8800
|
continue;
|
|
8376
8801
|
}
|
|
@@ -8382,7 +8807,7 @@ function unjudgedTweakIds(dir, record) {
|
|
|
8382
8807
|
}
|
|
8383
8808
|
function save(file, state) {
|
|
8384
8809
|
try {
|
|
8385
|
-
mkdirSync8(
|
|
8810
|
+
mkdirSync8(join43(file, ".."), { recursive: true });
|
|
8386
8811
|
writeFileSync6(file, JSON.stringify(state), "utf8");
|
|
8387
8812
|
} catch {
|
|
8388
8813
|
}
|
|
@@ -8390,7 +8815,7 @@ function save(file, state) {
|
|
|
8390
8815
|
|
|
8391
8816
|
// src/index.ts
|
|
8392
8817
|
var packageRoot = getPackageRoot();
|
|
8393
|
-
var { version } = JSON.parse(
|
|
8818
|
+
var { version } = JSON.parse(readFileSync37(join44(packageRoot, "package.json"), "utf8"));
|
|
8394
8819
|
function warnIfUnpublishedPin() {
|
|
8395
8820
|
if (isDevVersion(version)) {
|
|
8396
8821
|
console.warn(
|
|
@@ -8434,7 +8859,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
8434
8859
|
projectRoot,
|
|
8435
8860
|
packageRoot: assetRoot(),
|
|
8436
8861
|
version,
|
|
8437
|
-
homeDir:
|
|
8862
|
+
homeDir: homedir3(),
|
|
8438
8863
|
hook
|
|
8439
8864
|
});
|
|
8440
8865
|
if (result.warning) {
|
|
@@ -8443,6 +8868,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
8443
8868
|
}
|
|
8444
8869
|
if (result.hookOnly) {
|
|
8445
8870
|
console.log(result.hookOnly);
|
|
8871
|
+
for (const f of result.agentFiles ?? []) console.log(` + ${f} (Jig's block for your agent; your own text in it is untouched)`);
|
|
8446
8872
|
if (result.stopHook === true) console.log(" + .claude/settings.json (Stop hook: jig gate blocks finishing while check or a critique fails)");
|
|
8447
8873
|
if (result.stopHook === false) console.log(" ! .claude/settings.json is not valid JSON \u2014 the Stop hook was not added. Fix the file and run install again.");
|
|
8448
8874
|
return;
|
|
@@ -8451,6 +8877,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
8451
8877
|
warnIfUnpublishedPin();
|
|
8452
8878
|
for (const f of result.written) console.log(` + ${f}`);
|
|
8453
8879
|
for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
|
|
8880
|
+
for (const f of result.agentFiles ?? []) console.log(` + ${f} (Jig's block for your agent; your own text in it is untouched)`);
|
|
8454
8881
|
if (result.stopHook === true) console.log(" + .claude/settings.json (Stop hook: jig gate blocks finishing while check or a critique fails)");
|
|
8455
8882
|
if (result.stopHook === false) console.log(" ! .claude/settings.json is not valid JSON \u2014 the Stop hook was not added. Fix the file and run install again.");
|
|
8456
8883
|
if (result.stopHook === void 0 && opts.agent === "claude" && opts.scope === "project") {
|
|
@@ -8478,7 +8905,7 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
8478
8905
|
projectRoot,
|
|
8479
8906
|
packageRoot: assetRoot(),
|
|
8480
8907
|
version,
|
|
8481
|
-
homeDir:
|
|
8908
|
+
homeDir: homedir3()
|
|
8482
8909
|
});
|
|
8483
8910
|
const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
|
|
8484
8911
|
warnIfUnpublishedPin();
|
|
@@ -8497,6 +8924,16 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
8497
8924
|
process.exit(1);
|
|
8498
8925
|
}
|
|
8499
8926
|
});
|
|
8927
|
+
program.command("checksum").description("Print a spec's checksum, the value its .checked.json records as `spec`.").argument("<spec>", "the spec by name (tokens-and-modes) or path (.jig/specs/tokens-and-modes.spec.md)").action((name) => {
|
|
8928
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
8929
|
+
const slug = name.replace(/^.*\//, "").replace(/\.spec\.md$/, "");
|
|
8930
|
+
const path = join44(projectRoot, ".jig", "specs", `${slug}.spec.md`);
|
|
8931
|
+
if (!existsSync28(path)) {
|
|
8932
|
+
console.error(` \u2717 no spec at .jig/specs/${slug}.spec.md`);
|
|
8933
|
+
process.exit(1);
|
|
8934
|
+
}
|
|
8935
|
+
console.log(specChecksum(readFileSync37(path, "utf8")));
|
|
8936
|
+
});
|
|
8500
8937
|
program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").option("--reprobe", "re-take every probe that is missing or was taken on an older version of the page, first (needs a browser)").action(async (surface, opts) => {
|
|
8501
8938
|
const projectRoot = findProjectRoot(process.cwd());
|
|
8502
8939
|
try {
|
|
@@ -8516,6 +8953,10 @@ program.command("verdicts").description("Verify a critique's verdict files and c
|
|
|
8516
8953
|
console.error(` The page changed after these probes. \`jig verdicts ${surface} --reprobe\` re-takes them on the page as it is now.`);
|
|
8517
8954
|
}
|
|
8518
8955
|
if (result.ok) console.log(` Every rule in both passes has a verdict.`);
|
|
8956
|
+
if (result.measured.length) {
|
|
8957
|
+
console.log(` The page fails ${result.measured.length} check(s) the render probe measured. They are findings on the page, for make or tweak to fix; a critique reports them and leaves the page as it is:`);
|
|
8958
|
+
for (const failure of result.measured) console.log(` ! ${failure}`);
|
|
8959
|
+
}
|
|
8519
8960
|
const since = result.decisions.since ?? [];
|
|
8520
8961
|
if (since.length) console.log(` ${since.length} decision(s) recorded after this critique, for the next one to judge: ${since.join(", ")}.`);
|
|
8521
8962
|
const p = result.previous;
|
|
@@ -8536,18 +8977,19 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
8536
8977
|
// this as widening the RULES and attested a clean diff as a clean project.
|
|
8537
8978
|
"scan every file in the repo, not just those changed since HEAD (same rules either way)",
|
|
8538
8979
|
false
|
|
8539
|
-
).option("--ci", "mechanical bucket only
|
|
8980
|
+
).option("--ci", "mechanical bucket only, so the run is deterministic", false).option("--json", "emit findings as JSON", false).action((opts) => {
|
|
8540
8981
|
const projectRoot = findProjectRoot(process.cwd());
|
|
8541
8982
|
try {
|
|
8542
8983
|
const result = check({
|
|
8543
8984
|
projectRoot,
|
|
8544
|
-
homeDir:
|
|
8985
|
+
homeDir: homedir3(),
|
|
8545
8986
|
version,
|
|
8546
8987
|
all: opts.all,
|
|
8547
8988
|
ci: opts.ci
|
|
8548
8989
|
});
|
|
8549
8990
|
console.log(opts.json ? JSON.stringify(result.findings, null, 2) : result.report);
|
|
8550
|
-
|
|
8991
|
+
const code = checkExitCode(result);
|
|
8992
|
+
if (code !== 0) process.exit(code);
|
|
8551
8993
|
} catch (err) {
|
|
8552
8994
|
console.error(err.message);
|
|
8553
8995
|
process.exit(1);
|
|
@@ -8624,13 +9066,14 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
8624
9066
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
8625
9067
|
let input = {};
|
|
8626
9068
|
try {
|
|
8627
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
9069
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync37(0, "utf8") || "{}");
|
|
8628
9070
|
} catch {
|
|
8629
9071
|
}
|
|
8630
9072
|
const cwd = input.cwd ?? process.cwd();
|
|
8631
9073
|
try {
|
|
8632
9074
|
const projectRoot = findProjectRoot(cwd);
|
|
8633
|
-
const
|
|
9075
|
+
const byHand = !input.transcript_path;
|
|
9076
|
+
const critiqued = byHand ? [] : critiquedSurfaces(projectRoot);
|
|
8634
9077
|
for (const surface of surfacesToProbe(projectRoot, input).filter((s) => critiqued.includes(s))) {
|
|
8635
9078
|
const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
|
|
8636
9079
|
if (!page) continue;
|
|
@@ -8653,7 +9096,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
8653
9096
|
await init({
|
|
8654
9097
|
projectRoot,
|
|
8655
9098
|
packageRoot: assetRoot(),
|
|
8656
|
-
homeDir:
|
|
9099
|
+
homeDir: homedir3(),
|
|
8657
9100
|
version,
|
|
8658
9101
|
yes: opts.yes
|
|
8659
9102
|
});
|
|
@@ -8664,7 +9107,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
8664
9107
|
});
|
|
8665
9108
|
function resolveSurfaceMode(projectRoot) {
|
|
8666
9109
|
try {
|
|
8667
|
-
const config = JSON.parse(
|
|
9110
|
+
const config = JSON.parse(readFileSync37(join44(projectRoot, "jig.config.json"), "utf8"));
|
|
8668
9111
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
8669
9112
|
return modes.length === 1 ? modes[0] : void 0;
|
|
8670
9113
|
} catch {
|