jig-ui 0.2.1 → 0.4.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/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 readFileSync4 } from "fs";
5
+ import { readFileSync as readFileSync16 } from "fs";
6
6
  import { homedir } from "os";
7
- import { join as join5 } from "path";
7
+ import { join as join21 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -37,12 +37,16 @@ function assetRoot(startDir = getPackageRoot()) {
37
37
  current = dirname(current);
38
38
  }
39
39
  }
40
+ function isPublishedBuild(packageRoot2) {
41
+ return packageRoot2.split(/[\\/]/).includes("node_modules");
42
+ }
40
43
 
41
44
  // src/commands/install.ts
42
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, writeFileSync as writeFileSync2 } from "fs";
43
- import { dirname as dirname3, join as join3 } from "path";
45
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync3 } from "fs";
46
+ import { dirname as dirname4, join as join6 } from "path";
44
47
 
45
48
  // src/adapters/types.ts
49
+ var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, review the UI against the rules, or refresh the install.";
46
50
  var SKILL_DESCRIPTION = "Design system rules for generating and reviewing UI. Load before building any interface.";
47
51
  function assertSafeRelPath(relPath, adapterName) {
48
52
  const isAbsolute = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
@@ -56,92 +60,172 @@ function quoteYamlString(value) {
56
60
  }
57
61
  var BLOCK_START = "<!-- jig:start -->";
58
62
  var BLOCK_END = "<!-- jig:end -->";
59
- function agentsBlock(skillBody) {
60
- return [BLOCK_START, "", "# Jig \u2014 UI rules", "", skillBody, "", BLOCK_END, ""].join("\n");
63
+ function agentsPointer(skillPath) {
64
+ return [
65
+ BLOCK_START,
66
+ "",
67
+ "# Jig \u2014 UI rules",
68
+ "",
69
+ `This project uses Jig, a design system for generating and reviewing UI.`,
70
+ `Before building or reviewing any interface, read \`${skillPath}\` and follow it.`,
71
+ "It is short, and it tells you which rule files to load for the task at hand.",
72
+ "",
73
+ BLOCK_END,
74
+ ""
75
+ ].join("\n");
61
76
  }
62
77
 
63
- // src/adapters/claude.ts
64
- var claude = {
65
- name: "claude",
66
- displayName: "Claude Code",
67
- supportsScope: () => true,
68
- skillFiles(ctx) {
69
- const content = [
70
- "---",
71
- "name: jig",
72
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
73
- "user-invocable: true",
74
- "---",
75
- "",
76
- ctx.skillBody,
77
- ""
78
- ].join("\n");
79
- return [{ relPath: ".claude/skills/jig/SKILL.md", content }];
80
- }
81
- };
82
-
83
78
  // src/adapters/codex.ts
84
79
  var codex = {
85
80
  name: "codex",
86
81
  displayName: "Codex",
87
- supportsScope: () => true,
88
- skillFiles(ctx) {
89
- const relPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
90
- return [{ relPath, content: agentsBlock(ctx.skillBody) }];
91
- }
92
- };
93
-
94
- // src/adapters/cursor.ts
95
- var cursor = {
96
- name: "cursor",
97
- displayName: "Cursor",
98
- supportsScope: (scope) => scope === "project",
99
- skillFiles(ctx) {
100
- const content = [
101
- "---",
102
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
103
- "alwaysApply: false",
104
- "---",
105
- "",
106
- ctx.skillBody,
107
- ""
108
- ].join("\n");
109
- return [{ relPath: ".cursor/rules/jig.mdc", content }];
110
- }
111
- };
112
-
113
- // src/adapters/opencode.ts
114
- var opencode = {
115
- name: "opencode",
116
- displayName: "opencode",
117
- supportsScope: () => true,
118
82
  skillFiles(ctx) {
119
- const content = [
120
- "---",
121
- "name: jig",
122
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
123
- "---",
124
- "",
125
- ctx.skillBody,
126
- ""
127
- ].join("\n");
128
- const relPath = ctx.scope === "global" ? ".config/opencode/skills/jig/SKILL.md" : ".opencode/skills/jig/SKILL.md";
129
- return [{ relPath, content }];
130
- }
83
+ const skillPath = ".agents/skills/jig/SKILL.md";
84
+ const files = [{ relPath: skillPath, content: ctx.skillBody }];
85
+ const agentsPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
86
+ files.push({ relPath: agentsPath, content: agentsPointer(skillPath) });
87
+ if (ctx.commandBody && ctx.scope === "global") {
88
+ files.push({ relPath: ".codex/prompts/jig.md", content: `${ctx.commandBody}
89
+ ` });
90
+ }
91
+ return files;
92
+ },
93
+ /**
94
+ * codex has no skill directory: AGENTS.md is a plain file (project root, or
95
+ * `.codex/` for a global install). Reference material lives under `.codex/`
96
+ * at BOTH scopes.
97
+ *
98
+ * Project scope used to be a bare `.jig`, which put Jig's rules, index,
99
+ * licence and manifest into the one directory 0.4.0 reserves for the
100
+ * project's own material — the brand file, the mode copies, `state.json`.
101
+ * That cost more than tidiness:
102
+ *
103
+ * - `detectLegacyRules` scans `.jig/` for install artifacts and offers to
104
+ * remove them. Under the old layout a *live* codex install looked exactly
105
+ * like the legacy one, so the migration path could offer to delete the
106
+ * install just made.
107
+ * - One directory holding two manifests is what let a stale
108
+ * `.jig/manifest.json` hijack `update` and resurrect the vendored layout.
109
+ *
110
+ * Mirroring the global path keeps codex's own convention and leaves `.jig/`
111
+ * unambiguously the project's.
112
+ */
113
+ /**
114
+ * Beside the skill, like every other harness — the rules are what `SKILL.md`
115
+ * tells the agent to read, so they belong next to it. This used to be
116
+ * `.codex/.jig`, and before that a bare `.jig`, which put Jig's property in
117
+ * the one directory the project owns.
118
+ */
119
+ referenceDir: () => ".agents/skills/jig",
120
+ argsPlaceholder: "$ARGUMENTS"
131
121
  };
132
122
 
133
- // src/adapters/generic.ts
134
- var generic = {
135
- name: "generic",
136
- displayName: "Generic (AGENTS.md)",
137
- supportsScope: (scope) => scope === "project",
138
- skillFiles(ctx) {
139
- return [{ relPath: "AGENTS.md", content: agentsBlock(ctx.skillBody) }];
123
+ // src/adapters/skill-dir.ts
124
+ var SKILL_DIR_HARNESSES = [
125
+ {
126
+ name: "claude",
127
+ dir: ".claude",
128
+ displayName: "Claude Code",
129
+ extraFrontmatter: ["user-invocable: true"],
130
+ // `commands/<name>.md`, YAML frontmatter, `$ARGUMENTS` in the body —
131
+ // verified against the shipped command files under
132
+ // ~/.claude/plugins/marketplaces/*/plugins/*/commands/.
133
+ commands: { dir: "commands", format: "md" }
134
+ },
135
+ { name: "cursor", dir: ".cursor", displayName: "Cursor", commands: { dir: "commands", format: "md" } },
136
+ {
137
+ name: "opencode",
138
+ dir: ".opencode",
139
+ globalDir: ".config/opencode",
140
+ displayName: "opencode",
141
+ // Singular `command/`, unlike everyone else's `commands/`.
142
+ commands: { dir: "command", format: "md" }
143
+ },
144
+ // No slash-command mechanism of its own — `.agents` is a skills convention
145
+ // rather than a harness, so there is nothing to write a command file for.
146
+ { name: "generic", dir: ".agents", displayName: "Generic (.agents/skills)" },
147
+ // Proves the table design: this row is the entire diff needed to support
148
+ // a sixth harness. Gemini CLI, Copilot CLI and others read `.agents/skills`
149
+ // too, but Gemini also has its own `.gemini` skill directory.
150
+ {
151
+ name: "gemini",
152
+ dir: ".gemini",
153
+ displayName: "Gemini CLI",
154
+ // Gemini reads TOML, with `{{args}}` where the others use `$ARGUMENTS`.
155
+ commands: { dir: "commands", format: "toml" }
140
156
  }
141
- };
157
+ ];
158
+ function dirFor(h, scope) {
159
+ return scope === "global" && h.globalDir ? h.globalDir : h.dir;
160
+ }
161
+ function skillDirAdapter(h) {
162
+ return {
163
+ name: h.name,
164
+ displayName: h.displayName,
165
+ skillFiles(ctx) {
166
+ const content = [
167
+ "---",
168
+ "name: jig",
169
+ `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
170
+ ...h.extraFrontmatter ?? [],
171
+ "---",
172
+ "",
173
+ ctx.skillBody,
174
+ ""
175
+ ].join("\n");
176
+ const files = [
177
+ { relPath: `${dirFor(h, ctx.scope)}/skills/jig/SKILL.md`, content }
178
+ ];
179
+ if (h.commands && ctx.commandBody) {
180
+ files.push({
181
+ relPath: `${dirFor(h, ctx.scope)}/${h.commands.dir}/jig.${h.commands.format}`,
182
+ content: h.commands.format === "toml" ? tomlCommand(ctx.commandBody) : markdownCommand(ctx.commandBody, ctx.subcommands ?? [])
183
+ });
184
+ }
185
+ return files;
186
+ },
187
+ referenceDir(scope) {
188
+ return `${dirFor(h, scope)}/skills/jig`;
189
+ },
190
+ argsPlaceholder: h.commands?.format === "toml" ? "{{args}}" : "$ARGUMENTS"
191
+ };
192
+ }
193
+ function markdownCommand(body, subcommands) {
194
+ return [
195
+ "---",
196
+ `description: ${quoteYamlString(COMMAND_DESCRIPTION)}`,
197
+ `argument-hint: ${subcommands.join("|")} [flags]`,
198
+ "---",
199
+ "",
200
+ body,
201
+ ""
202
+ ].join("\n");
203
+ }
204
+ var TOML_DELIM = "'''";
205
+ function tomlCommand(body) {
206
+ const safe = body.split(TOML_DELIM).join('"""');
207
+ return [
208
+ `description = ${JSON.stringify(COMMAND_DESCRIPTION)}`,
209
+ `prompt = ${TOML_DELIM}`,
210
+ safe,
211
+ TOML_DELIM,
212
+ ""
213
+ ].join("\n");
214
+ }
215
+ var SKILL_DIR_ADAPTERS = SKILL_DIR_HARNESSES.map(skillDirAdapter);
142
216
 
143
217
  // src/adapters/registry.ts
144
- var ADAPTERS = [claude, codex, cursor, opencode, generic];
218
+ var ADAPTERS = [codex, ...SKILL_DIR_ADAPTERS];
219
+ function referenceDirFor(adapter, scope) {
220
+ const dir = adapter.referenceDir(scope);
221
+ assertSafeRelPath(dir, adapter.name);
222
+ return dir;
223
+ }
224
+ for (const adapter of ADAPTERS) {
225
+ for (const scope of ["project", "global"]) {
226
+ assertSafeRelPath(adapter.referenceDir(scope), adapter.name);
227
+ }
228
+ }
145
229
  function adapterNames() {
146
230
  return ADAPTERS.map((a) => a.name);
147
231
  }
@@ -160,12 +244,59 @@ function skillFilesFor(adapter, ctx) {
160
244
  return files;
161
245
  }
162
246
 
247
+ // src/install/references.ts
248
+ import { existsSync as existsSync2, readFileSync, readdirSync } from "fs";
249
+ import { join as join2, posix } from "path";
250
+ function referenceFiles(packageRoot2) {
251
+ const root = join2(packageRoot2, "references");
252
+ if (!existsSync2(root)) return [];
253
+ const found = [];
254
+ const walk2 = (dir, prefix) => {
255
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
256
+ (a, b) => a.name.localeCompare(b.name)
257
+ )) {
258
+ const child = join2(dir, entry.name);
259
+ const rel = prefix ? posix.join(prefix, entry.name) : entry.name;
260
+ if (entry.isDirectory()) {
261
+ walk2(child, rel);
262
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
263
+ found.push({ relPath: rel, content: readFileSync(child, "utf8") });
264
+ }
265
+ }
266
+ };
267
+ walk2(root, "");
268
+ return found;
269
+ }
270
+
163
271
  // src/install/manifest.ts
164
272
  import { createHash } from "crypto";
165
- import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
166
- import { dirname as dirname2, join as join2 } from "path";
167
- var MANIFEST_REL = join2(".jig", "manifest.json");
273
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "fs";
274
+ import { dirname as dirname3, join as join4 } from "path";
275
+
276
+ // src/install/atomic.ts
277
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
278
+ import { dirname as dirname2, join as join3 } from "path";
279
+ function writeFileAtomic(path, content) {
280
+ const dir = dirname2(path);
281
+ mkdirSync(dir, { recursive: true });
282
+ const tmp = join3(dir, `.${process.pid}-${Date.now()}.tmp`);
283
+ try {
284
+ writeFileSync(tmp, content, "utf8");
285
+ renameSync(tmp, path);
286
+ } catch (err) {
287
+ try {
288
+ unlinkSync(tmp);
289
+ } catch {
290
+ }
291
+ throw err;
292
+ }
293
+ }
294
+
295
+ // src/install/manifest.ts
168
296
  var REGENERATE_HINT = "Re-run 'jig install --agent <name>' to fix it.";
297
+ function manifestPath(root, manifestDir) {
298
+ return join4(root, ...manifestDir.split("/"), "manifest.json");
299
+ }
169
300
  function checksum(content) {
170
301
  const normalized = content.replace(/\r\n/g, "\n");
171
302
  return `sha256:${createHash("sha256").update(normalized, "utf8").digest("hex")}`;
@@ -200,29 +331,45 @@ function validateManifest(raw, path) {
200
331
  files
201
332
  };
202
333
  }
203
- function readManifest(projectRoot) {
204
- const path = join2(projectRoot, MANIFEST_REL);
205
- if (!existsSync2(path)) return null;
334
+ function readManifest(root, manifestDir = ".jig") {
335
+ const path = manifestPath(root, manifestDir);
336
+ if (!existsSync3(path)) return null;
206
337
  let raw;
207
338
  try {
208
- raw = JSON.parse(readFileSync(path, "utf8"));
339
+ raw = JSON.parse(readFileSync2(path, "utf8"));
209
340
  } catch {
210
341
  throw new Error(`Jig's manifest at ${path} is not valid JSON. ${REGENERATE_HINT}`);
211
342
  }
212
343
  return validateManifest(raw, path);
213
344
  }
214
- function writeManifest(projectRoot, m) {
215
- const path = join2(projectRoot, MANIFEST_REL);
216
- mkdirSync(dirname2(path), { recursive: true });
217
- writeFileSync(path, `${JSON.stringify(m, null, 2)}
218
- `, "utf8");
345
+ var MERGE_ATTEMPTS = 5;
346
+ function writeManifest(root, m, manifestDir = ".jig") {
347
+ const path = manifestPath(root, manifestDir);
348
+ mkdirSync2(dirname3(path), { recursive: true });
349
+ for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
350
+ const onDisk = readManifestQuietly(path);
351
+ const merged = { ...m, files: { ...onDisk?.files ?? {}, ...m.files } };
352
+ writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
353
+ `);
354
+ const after = readManifestQuietly(path);
355
+ if (after && Object.keys(merged.files).every((k) => k in after.files)) return;
356
+ }
357
+ }
358
+ function readManifestQuietly(path) {
359
+ try {
360
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
361
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.files)) return null;
362
+ return parsed;
363
+ } catch {
364
+ return null;
365
+ }
219
366
  }
220
367
  function isModified(projectRoot, relPath, m) {
221
368
  const recorded = m.files[relPath];
222
369
  if (!recorded) return false;
223
- const abs = join2(projectRoot, relPath);
224
- if (!existsSync2(abs)) return false;
225
- return checksum(readFileSync(abs, "utf8")) !== recorded;
370
+ const abs = join4(projectRoot, relPath);
371
+ if (!existsSync3(abs)) return false;
372
+ return checksum(readFileSync2(abs, "utf8")) !== recorded;
226
373
  }
227
374
 
228
375
  // src/template/render.ts
@@ -250,15 +397,22 @@ function renderCommandTable(metadata) {
250
397
  }
251
398
 
252
399
  // src/install/vendor.ts
400
+ import { posix as posix2 } from "path";
401
+ var { join: join5 } = posix2;
253
402
  var DELIMITERS = {
254
403
  html: ["<!--", "-->"],
255
404
  css: ["/*", "*/"]
256
405
  };
257
- function vendorHeader(file, version2, style = "html") {
406
+ function licencePathFor(relPath) {
407
+ const depth = relPath.split("/").length - 1;
408
+ return depth === 0 ? "." : Array(depth).fill("..").join("/");
409
+ }
410
+ function vendorHeader(file, version2, style = "html", licencePath = ".") {
258
411
  const [open, close] = DELIMITERS[style];
412
+ 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")}.`;
259
413
  return [
260
414
  `${open} ${file} \u2014 vendored from Jig v${version2}.`,
261
- " Licensed Apache-2.0. See .jig/LICENSE and .jig/NOTICE.",
415
+ attribution,
262
416
  ` Edit freely: \`jig update\` will not overwrite a file you have changed. ${close}`,
263
417
  "",
264
418
  ""
@@ -301,16 +455,16 @@ function stripMarkerRegions(existing) {
301
455
  return { text: existing, insertAt: existing.length, removed: false };
302
456
  }
303
457
  let text = "";
304
- let cursor2 = 0;
458
+ let cursor = 0;
305
459
  let insertAt = -1;
306
460
  regions.forEach((region, i) => {
307
- let chunk = existing.slice(cursor2, region.start);
461
+ let chunk = existing.slice(cursor, region.start);
308
462
  if (i > 0) chunk = chunk.replace(/^\n/, "");
309
463
  text += chunk;
310
464
  if (insertAt === -1) insertAt = text.length;
311
- cursor2 = region.end;
465
+ cursor = region.end;
312
466
  });
313
- const tail = existing.slice(cursor2).replace(/^\n/, "");
467
+ const tail = existing.slice(cursor).replace(/^\n/, "");
314
468
  text += tail;
315
469
  return { text, insertAt, removed: true };
316
470
  }
@@ -323,82 +477,140 @@ function upsertBlock(existing, block) {
323
477
  return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
324
478
  }
325
479
 
480
+ // src/install/line-endings.ts
481
+ function dominantEnding(existing) {
482
+ const crlf = (existing.match(/\r\n/g) ?? []).length;
483
+ const lf = (existing.match(/(?<!\r)\n/g) ?? []).length;
484
+ if (crlf === 0 && lf === 0) return "\n";
485
+ return crlf >= lf ? "\r\n" : "\n";
486
+ }
487
+ function matchLineEndings(content, existing) {
488
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
489
+ const target = existing === void 0 ? "\n" : dominantEnding(existing);
490
+ return target === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
491
+ }
492
+
326
493
  // src/commands/install.ts
327
494
  var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
328
495
  function relKey(...parts) {
329
496
  return parts.join("/");
330
497
  }
331
498
  function writeFile(root, key, content, files) {
332
- const abs = join3(root, ...key.split("/"));
333
- mkdirSync2(dirname3(abs), { recursive: true });
334
- writeFileSync2(abs, content, "utf8");
335
- files[key] = checksum(content);
499
+ const abs = join6(root, ...key.split("/"));
500
+ mkdirSync3(dirname4(abs), { recursive: true });
501
+ const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
502
+ const toWrite = matchLineEndings(content, existing);
503
+ writeFileSync3(abs, toWrite, "utf8");
504
+ files[key] = checksum(toWrite);
336
505
  }
337
- function buildSkillBody(packageRoot2, scope) {
338
- const template = readFileSync2(join3(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
506
+ function buildSkillBody(packageRoot2, rulesPath, version2) {
507
+ const template = readFileSync3(join6(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
339
508
  const metadata = JSON.parse(
340
- readFileSync2(join3(packageRoot2, "templates", "command-metadata.json"), "utf8")
509
+ readFileSync3(join6(packageRoot2, "templates", "command-metadata.json"), "utf8")
341
510
  );
342
511
  return render(template, {
343
512
  command_prefix: "/jig ",
344
- scripts_path: "npx jig-ui",
513
+ scripts_path: version2 ? `npx jig-ui@${version2}` : "npx jig-ui",
514
+ // `update` must NOT carry the pin. Pinned, it refreshes to the version
515
+ // already installed — reporting "Updated Jig → <same version>" for a no-op
516
+ // — so a reader following the skill could never upgrade.
517
+ update_path: "npx jig-ui@latest",
345
518
  ask_instruction: ASK_INSTRUCTION,
346
519
  available_commands: renderCommandTable(metadata),
347
520
  config_file: "jig.config.json",
348
- rules_path: scope === "global" ? "~/.jig" : ".jig"
521
+ rules_path: rulesPath
349
522
  });
350
523
  }
524
+ function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
525
+ const metadata = JSON.parse(
526
+ readFileSync3(join6(packageRoot2, "templates", "command-metadata.json"), "utf8")
527
+ );
528
+ const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available").map(([name]) => name).sort();
529
+ const templatePath = join6(packageRoot2, "templates", "COMMAND.md.tmpl");
530
+ if (!existsSync4(templatePath)) return { body: "", subcommands };
531
+ const template = readFileSync3(templatePath, "utf8");
532
+ const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{rules_path\}\}/g, rulesPath);
533
+ return { body, subcommands };
534
+ }
535
+ function rulesPathFor(referenceDir, scope) {
536
+ return scope === "global" ? `~/${referenceDir}/rules` : `${referenceDir}/rules`;
537
+ }
351
538
  function install(opts) {
352
539
  const adapter = getAdapter(opts.agent);
353
- if (!adapter.supportsScope(opts.scope)) {
354
- throw new Error(`Adapter '${adapter.name}' does not support ${opts.scope} scope.`);
540
+ if (opts.scope === "project") {
541
+ const globalReferenceDir = referenceDirFor(adapter, "global");
542
+ let globalManifest;
543
+ try {
544
+ globalManifest = readManifest(opts.homeDir, globalReferenceDir);
545
+ } catch {
546
+ globalManifest = null;
547
+ }
548
+ if (globalManifest) {
549
+ return {
550
+ written: [],
551
+ skipped: [],
552
+ warning: `Jig is already installed globally for '${opts.agent}' (${globalReferenceDir} under your home directory). Installing again at project scope would leave two contradicting '${opts.agent}' skills. Run 'jig update' to refresh the global install instead, or choose a different --agent for this project.`
553
+ };
554
+ }
355
555
  }
356
556
  const installRoot = opts.scope === "global" ? opts.homeDir : opts.projectRoot;
557
+ const referenceDir = referenceDirFor(adapter, opts.scope);
357
558
  let existingManifest;
358
559
  try {
359
- existingManifest = readManifest(installRoot);
560
+ existingManifest = readManifest(installRoot, referenceDir);
360
561
  } catch {
361
562
  existingManifest = null;
362
563
  }
363
564
  const planned = [];
364
- const rulesDir = join3(opts.packageRoot, "rules");
365
- for (const file of readdirSync(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
366
- const body = readFileSync2(join3(rulesDir, file), "utf8");
367
- planned.push({ key: relKey(".jig", file), content: vendorHeader(file, opts.version) + body, checkable: true });
368
- }
369
- const tokensDir = join3(opts.packageRoot, "tokens");
370
- for (const file of readdirSync(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
371
- const body = readFileSync2(join3(tokensDir, file), "utf8");
565
+ const rulesDir = join6(opts.packageRoot, "rules");
566
+ for (const file of readdirSync2(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
567
+ const body = readFileSync3(join6(rulesDir, file), "utf8");
568
+ planned.push({
569
+ key: relKey(referenceDir, "rules", file),
570
+ content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
571
+ checkable: true
572
+ });
573
+ }
574
+ for (const ref of referenceFiles(opts.packageRoot)) {
372
575
  planned.push({
373
- key: relKey(".jig", "tokens", file),
374
- content: vendorHeader(file, opts.version, "css") + body,
576
+ key: relKey(referenceDir, ...ref.relPath.split("/")),
577
+ content: vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content,
375
578
  checkable: true
376
579
  });
377
580
  }
378
581
  planned.push({
379
- key: relKey(".jig", "rules.index.json"),
380
- content: readFileSync2(join3(opts.packageRoot, "rules.index.json"), "utf8"),
582
+ key: relKey(referenceDir, "rules.index.json"),
583
+ content: readFileSync3(join6(opts.packageRoot, "rules.index.json"), "utf8"),
381
584
  checkable: true
382
585
  });
383
586
  for (const src of ["LICENSE", "NOTICE"]) {
384
587
  planned.push({
385
- key: relKey(".jig", src),
386
- content: readFileSync2(join3(opts.packageRoot, src), "utf8"),
588
+ key: relKey(referenceDir, src),
589
+ content: readFileSync3(join6(opts.packageRoot, src), "utf8"),
387
590
  checkable: false
388
591
  });
389
592
  }
390
- const skillBody = buildSkillBody(opts.packageRoot, opts.scope);
593
+ const rulesPath = rulesPathFor(referenceDir, opts.scope);
594
+ const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
595
+ const { body: commandBody, subcommands } = buildCommandBody(
596
+ opts.packageRoot,
597
+ rulesPath,
598
+ opts.version,
599
+ adapter.argsPlaceholder ?? "$ARGUMENTS"
600
+ );
601
+ const command = { commandBody, subcommands };
391
602
  const skillFiles = skillFilesFor(adapter, {
392
603
  version: opts.version,
393
604
  scope: opts.scope,
394
605
  skillBody,
395
- commandPrefix: "/jig "
606
+ commandPrefix: "/jig ",
607
+ ...command
396
608
  });
397
609
  for (const file of skillFiles) {
398
610
  const key = file.relPath;
399
- const abs = join3(installRoot, ...key.split("/"));
611
+ const abs = join6(installRoot, ...key.split("/"));
400
612
  const isBlockFile = file.content.includes(BLOCK_START);
401
- const content = isBlockFile && existsSync3(abs) ? upsertBlock(readFileSync2(abs, "utf8"), file.content) : file.content;
613
+ const content = isBlockFile && existsSync4(abs) ? upsertBlock(readFileSync3(abs, "utf8"), file.content) : file.content;
402
614
  planned.push({ key, content, checkable: !isBlockFile });
403
615
  }
404
616
  const files = {};
@@ -421,79 +633,296 @@ function install(opts) {
421
633
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
422
634
  files
423
635
  };
424
- writeManifest(installRoot, manifest);
425
- written.push(relKey(".jig", "manifest.json"));
636
+ writeManifest(installRoot, manifest, referenceDir);
637
+ written.push(relKey(referenceDir, "manifest.json"));
426
638
  return { written, skipped };
427
639
  }
428
640
 
429
641
  // src/commands/update.ts
430
- import { basename, dirname as dirname4, join as join4, resolve } from "path";
431
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync3 } from "fs";
432
- var ALWAYS_REPLACE = [relKey(".jig", "LICENSE"), relKey(".jig", "NOTICE")];
642
+ import { dirname as dirname6, join as join9 } from "path";
643
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync6, readdirSync as readdirSync4, writeFileSync as writeFileSync5 } from "fs";
644
+
645
+ // src/install/target.ts
646
+ import { resolve } from "path";
647
+ function resolveAllInstalled(projectRoot, homeDir) {
648
+ const found = [];
649
+ const seen = /* @__PURE__ */ new Set();
650
+ const sameRoot = resolve(projectRoot) === resolve(homeDir);
651
+ for (const adapter of ADAPTERS) {
652
+ const referenceDir = referenceDirFor(adapter, "project");
653
+ const manifest = readManifest(projectRoot, referenceDir);
654
+ if (manifest && manifest.agent !== adapter.name) {
655
+ getAdapter(manifest.agent);
656
+ }
657
+ if (manifest && manifest.agent === adapter.name) {
658
+ const collides = sameRoot && referenceDirFor(adapter, "global") === referenceDir;
659
+ const scope = collides ? manifest.scope : "project";
660
+ const installRoot = scope === "global" ? homeDir : projectRoot;
661
+ const key = `${resolve(installRoot)}\0${referenceDir}`;
662
+ if (!seen.has(key)) {
663
+ seen.add(key);
664
+ found.push({ installRoot, scope, manifest, referenceDir });
665
+ }
666
+ }
667
+ }
668
+ for (const adapter of ADAPTERS) {
669
+ const referenceDir = referenceDirFor(adapter, "global");
670
+ const manifest = readManifest(homeDir, referenceDir);
671
+ if (manifest && manifest.agent !== adapter.name) {
672
+ getAdapter(manifest.agent);
673
+ }
674
+ if (manifest && manifest.agent === adapter.name) {
675
+ const key = `${resolve(homeDir)}\0${referenceDir}`;
676
+ if (!seen.has(key)) {
677
+ seen.add(key);
678
+ found.push({ installRoot: homeDir, scope: "global", manifest, referenceDir });
679
+ }
680
+ }
681
+ }
682
+ return found;
683
+ }
684
+
685
+ // src/init/state.ts
686
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
687
+ import { dirname as dirname5, join as join7 } from "path";
688
+ var INIT_MANIFEST_REL = join7(".jig", "state.json");
689
+ function readInitManifest(projectRoot) {
690
+ const path = join7(projectRoot, INIT_MANIFEST_REL);
691
+ if (!existsSync5(path)) return null;
692
+ try {
693
+ const raw = JSON.parse(readFileSync4(path, "utf8"));
694
+ if (typeof raw !== "object" || raw === null) return null;
695
+ const { files, version: version2, modes } = raw;
696
+ if (typeof files !== "object" || files === null) return null;
697
+ return {
698
+ files,
699
+ version: typeof version2 === "string" ? version2 : void 0,
700
+ modes: Array.isArray(modes) && modes.every((m) => typeof m === "string") ? modes : void 0
701
+ };
702
+ } catch {
703
+ return null;
704
+ }
705
+ }
706
+ var MERGE_ATTEMPTS2 = 5;
707
+ function writeInitManifest(projectRoot, m) {
708
+ const path = join7(projectRoot, INIT_MANIFEST_REL);
709
+ mkdirSync4(dirname5(path), { recursive: true });
710
+ for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
711
+ const onDisk = readInitManifest(projectRoot);
712
+ const merged = {
713
+ ...m,
714
+ modes: [.../* @__PURE__ */ new Set([...onDisk?.modes ?? [], ...m.modes ?? []])],
715
+ files: { ...onDisk?.files ?? {}, ...m.files }
716
+ };
717
+ writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
718
+ `);
719
+ const after = readInitManifest(projectRoot);
720
+ if (after && Object.keys(merged.files).every((k) => k in after.files)) return;
721
+ }
722
+ }
723
+ function isInitFileModified(projectRoot, relPath, m) {
724
+ const asManifest = { version: "", agent: "", scope: "project", installedAt: "", files: m.files };
725
+ return isModified(projectRoot, relPath, asManifest);
726
+ }
727
+
728
+ // src/init/migrate.ts
729
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync } from "fs";
730
+ import { join as join8 } from "path";
731
+ var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
732
+ function isLegacyInstallArtifact(name) {
733
+ return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
734
+ }
735
+ function readLegacyManifest(dirAbs) {
736
+ try {
737
+ const raw = JSON.parse(readFileSync5(join8(dirAbs, "manifest.json"), "utf8"));
738
+ if (raw && typeof raw === "object" && typeof raw.files === "object") {
739
+ return raw;
740
+ }
741
+ } catch {
742
+ }
743
+ return null;
744
+ }
745
+ function classify(projectRoot, relPath, legacyManifest) {
746
+ return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
747
+ }
748
+ function detectLegacyRules(projectRoot) {
749
+ const jigDir = join8(projectRoot, ".jig");
750
+ if (!existsSync6(jigDir)) return { present: false, files: [] };
751
+ const legacyManifest = readLegacyManifest(jigDir);
752
+ let entries;
753
+ try {
754
+ entries = readdirSync3(jigDir);
755
+ } catch {
756
+ entries = [];
757
+ }
758
+ const files = entries.filter(isLegacyInstallArtifact).sort().map((name) => {
759
+ const relPath = `.jig/${name}`;
760
+ return { relPath, modified: classify(projectRoot, relPath, legacyManifest) };
761
+ });
762
+ return { present: files.length > 0, files };
763
+ }
764
+ function detectLegacyCursorRules(projectRoot) {
765
+ const bundleDirRel = ".cursor/rules/jig";
766
+ const bundleDir = join8(projectRoot, ...bundleDirRel.split("/"));
767
+ const mdcRel = ".cursor/rules/jig.mdc";
768
+ const mdcAbs = join8(projectRoot, ...mdcRel.split("/"));
769
+ const legacyManifest = readLegacyManifest(bundleDir);
770
+ const files = [];
771
+ if (existsSync6(mdcAbs)) {
772
+ files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
773
+ }
774
+ let entries;
775
+ try {
776
+ entries = readdirSync3(bundleDir);
777
+ } catch {
778
+ entries = [];
779
+ }
780
+ for (const name of entries.filter(isLegacyInstallArtifact).sort()) {
781
+ const relPath = `${bundleDirRel}/${name}`;
782
+ files.push({ relPath, modified: classify(projectRoot, relPath, legacyManifest) });
783
+ }
784
+ return { present: files.length > 0, files };
785
+ }
786
+ function describeLegacyFiles(report, headerLine, subheaderLine) {
787
+ if (!report.present) return [];
788
+ const lines = [];
789
+ lines.push(`
790
+ ${headerLine}`);
791
+ lines.push(subheaderLine);
792
+ for (const f of report.files) lines.push(` ${f.relPath}`);
793
+ const edited = report.files.filter((f) => f.modified === true).map((f) => f.relPath);
794
+ const unknown = report.files.filter((f) => f.modified === "unknown").map((f) => f.relPath);
795
+ if (edited.length) {
796
+ lines.push(" Edited since install \u2014 kept, never removed automatically:");
797
+ for (const relPath of edited) lines.push(` ${relPath}`);
798
+ }
799
+ if (unknown.length) {
800
+ lines.push(" No install record to verify against \u2014 kept; remove manually if you are sure they are untouched:");
801
+ for (const relPath of unknown) lines.push(` ${relPath}`);
802
+ }
803
+ return lines;
804
+ }
805
+ function describeLegacyReport(report) {
806
+ return describeLegacyFiles(
807
+ report,
808
+ `Found a pre-0.4.0 Jig install in .jig/ (${report.files.length} file(s)). These are no longer used \u2014`,
809
+ " since 0.4.0 the skill and its rules live in your agent's skill directory, not the project:"
810
+ );
811
+ }
812
+ function describeLegacyCursorReport(report) {
813
+ return describeLegacyFiles(
814
+ report,
815
+ `Found a legacy Cursor install (${report.files.length} file(s)). Cursor's skill has moved \u2014`,
816
+ " from .cursor/rules/jig.mdc to .cursor/skills/jig/SKILL.md; these are no longer read:"
817
+ );
818
+ }
819
+ function removableLegacyFiles(report) {
820
+ return report.files.filter((f) => f.modified === false).map((f) => f.relPath);
821
+ }
822
+ function removeLegacyFiles(projectRoot, relPaths) {
823
+ for (const relPath of relPaths) {
824
+ try {
825
+ rmSync(join8(projectRoot, ...relPath.split("/")), { force: true });
826
+ } catch {
827
+ }
828
+ }
829
+ }
830
+
831
+ // src/commands/update.ts
433
832
  function update(opts) {
434
- const projectManifest = readManifest(opts.projectRoot);
435
- const existing = projectManifest ?? readManifest(opts.homeDir);
436
- if (!existing) {
833
+ const resolved = resolveAllInstalled(opts.projectRoot, opts.homeDir);
834
+ if (resolved.length === 0) {
835
+ const legacy = detectLegacyRules(opts.projectRoot);
836
+ const hint = legacy.present ? ` A pre-0.4.0 bundle is still in .jig/ (${legacy.files.length} file(s)); since 0.4.0 the skill and its rules live beside your agent's skill file instead, so that copy is no longer read. Re-run install, then 'jig init' will report the leftovers \u2014 it never removes a file you have edited.` : "";
437
837
  throw new Error(
438
- `Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`
838
+ `Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.${hint}`
439
839
  );
440
840
  }
441
- const sameRoot = resolve(opts.projectRoot) === resolve(opts.homeDir);
442
- const discoveredScope = !projectManifest ? "global" : sameRoot ? existing.scope : "project";
443
- const installRoot = discoveredScope === "global" ? opts.homeDir : opts.projectRoot;
444
- const adapter = getAdapter(existing.agent);
445
- if (!adapter.supportsScope(discoveredScope)) {
446
- throw new Error(
447
- `Jig's manifest at ${installRoot} records agent '${existing.agent}', which does not support ${discoveredScope} scope. Re-run 'jig install --agent <name>' to fix it.`
448
- );
841
+ const updated = [];
842
+ const skipped = [];
843
+ const targets = [];
844
+ for (const target of resolved) {
845
+ const one = updateTarget(opts, target);
846
+ updated.push(...one.updated);
847
+ skipped.push(...one.skipped);
848
+ targets.push({
849
+ agent: target.manifest.agent,
850
+ scope: target.scope,
851
+ referenceDir: target.referenceDir,
852
+ fromVersion: target.manifest.version
853
+ });
449
854
  }
855
+ const initResult = updateInitFiles(opts);
856
+ updated.push(...initResult.updated);
857
+ skipped.push(...initResult.skipped);
858
+ return {
859
+ updated,
860
+ skipped,
861
+ fromVersion: resolved[0].manifest.version,
862
+ toVersion: opts.version,
863
+ targets
864
+ };
865
+ }
866
+ function updateTarget(opts, resolved) {
867
+ const { installRoot, scope: discoveredScope, manifest: existing, referenceDir } = resolved;
868
+ const adapter = getAdapter(existing.agent);
450
869
  const updated = [];
451
870
  const skipped = [];
452
871
  const files = { ...existing.files };
453
872
  const write = (key, content) => {
454
- const abs = join4(installRoot, ...key.split("/"));
455
- mkdirSync3(dirname4(abs), { recursive: true });
456
- writeFileSync3(abs, content, "utf8");
457
- files[key] = checksum(content);
873
+ const abs = join9(installRoot, ...key.split("/"));
874
+ mkdirSync5(dirname6(abs), { recursive: true });
875
+ const existing2 = existsSync7(abs) ? readFileSync6(abs, "utf8") : void 0;
876
+ const toWrite = matchLineEndings(content, existing2);
877
+ writeFileSync5(abs, toWrite, "utf8");
878
+ files[key] = checksum(toWrite);
458
879
  updated.push(key);
459
880
  };
460
- const rulesDir = join4(opts.packageRoot, "rules");
461
- for (const file of readdirSync2(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
462
- const key = relKey(".jig", file);
881
+ const rulesDir = join9(opts.packageRoot, "rules");
882
+ for (const file of readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
883
+ const key = relKey(referenceDir, "rules", file);
463
884
  if (isModified(installRoot, key, existing)) {
464
885
  skipped.push(key);
465
886
  continue;
466
887
  }
467
- write(key, vendorHeader(file, opts.version) + readFileSync3(join4(rulesDir, file), "utf8"));
888
+ write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync6(join9(rulesDir, file), "utf8"));
468
889
  }
469
- const tokensDir = join4(opts.packageRoot, "tokens");
470
- for (const file of readdirSync2(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
471
- const key = relKey(".jig", "tokens", file);
890
+ for (const ref of referenceFiles(opts.packageRoot)) {
891
+ const key = relKey(referenceDir, ...ref.relPath.split("/"));
472
892
  if (isModified(installRoot, key, existing)) {
473
893
  skipped.push(key);
474
894
  continue;
475
895
  }
476
- write(key, vendorHeader(file, opts.version, "css") + readFileSync3(join4(tokensDir, file), "utf8"));
896
+ write(key, vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content);
477
897
  }
478
- const indexKey = relKey(".jig", "rules.index.json");
898
+ const indexKey = relKey(referenceDir, "rules.index.json");
479
899
  if (isModified(installRoot, indexKey, existing)) {
480
900
  skipped.push(indexKey);
481
901
  } else {
482
- write(indexKey, readFileSync3(join4(opts.packageRoot, "rules.index.json"), "utf8"));
902
+ write(indexKey, readFileSync6(join9(opts.packageRoot, "rules.index.json"), "utf8"));
483
903
  }
484
- const skillBody = buildSkillBody(opts.packageRoot, discoveredScope);
904
+ const rulesPath = rulesPathFor(referenceDir, discoveredScope);
905
+ const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
906
+ const { body: commandBody, subcommands } = buildCommandBody(
907
+ opts.packageRoot,
908
+ rulesPath,
909
+ opts.version,
910
+ adapter.argsPlaceholder ?? "$ARGUMENTS"
911
+ );
912
+ const command = { commandBody, subcommands };
485
913
  const skillFiles = skillFilesFor(adapter, {
486
914
  version: opts.version,
487
915
  scope: discoveredScope,
488
916
  skillBody,
489
- commandPrefix: "/jig "
917
+ commandPrefix: "/jig ",
918
+ ...command
490
919
  });
491
920
  for (const file of skillFiles) {
492
921
  const key = file.relPath;
493
922
  const isBlockFile = file.content.includes(BLOCK_START);
494
923
  if (isBlockFile) {
495
- const abs = join4(installRoot, ...key.split("/"));
496
- const current = existsSync4(abs) ? readFileSync3(abs, "utf8") : "";
924
+ const abs = join9(installRoot, ...key.split("/"));
925
+ const current = existsSync7(abs) ? readFileSync6(abs, "utf8") : "";
497
926
  write(key, upsertBlock(current, file.content));
498
927
  continue;
499
928
  }
@@ -503,8 +932,8 @@ function update(opts) {
503
932
  }
504
933
  write(key, file.content);
505
934
  }
506
- for (const key of ALWAYS_REPLACE) {
507
- write(key, readFileSync3(join4(opts.packageRoot, basename(key)), "utf8"));
935
+ for (const src of ["LICENSE", "NOTICE"]) {
936
+ write(relKey(referenceDir, src), readFileSync6(join9(opts.packageRoot, src), "utf8"));
508
937
  }
509
938
  const manifest = {
510
939
  ...existing,
@@ -513,42 +942,2510 @@ function update(opts) {
513
942
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
514
943
  files
515
944
  };
516
- writeManifest(installRoot, manifest);
517
- return { updated, skipped, fromVersion: existing.version, toVersion: opts.version };
945
+ writeManifest(installRoot, manifest, referenceDir);
946
+ return { updated, skipped };
947
+ }
948
+ function updateInitFiles(opts) {
949
+ const updated = [];
950
+ const skipped = [];
951
+ const tokensDir = join9(opts.packageRoot, "tokens");
952
+ const initManifest = readInitManifest(opts.projectRoot);
953
+ if (initManifest) {
954
+ const initFiles = { ...initManifest.files };
955
+ let initChanged = false;
956
+ for (const file of readdirSync4(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
957
+ const key = relKey(".jig", "tokens", file);
958
+ if (!(key in initManifest.files)) continue;
959
+ if (isInitFileModified(opts.projectRoot, key, initManifest)) {
960
+ skipped.push(key);
961
+ continue;
962
+ }
963
+ const content = vendorHeader(file, opts.version, "css", null) + readFileSync6(join9(tokensDir, file), "utf8");
964
+ const abs = join9(opts.projectRoot, ...key.split("/"));
965
+ mkdirSync5(dirname6(abs), { recursive: true });
966
+ writeFileSync5(abs, content, "utf8");
967
+ initFiles[key] = checksum(content);
968
+ initChanged = true;
969
+ updated.push(key);
970
+ }
971
+ if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
972
+ }
973
+ return { updated, skipped };
518
974
  }
519
975
 
520
- // src/index.ts
521
- var packageRoot = getPackageRoot();
522
- var { version } = JSON.parse(readFileSync4(join5(packageRoot, "package.json"), "utf8"));
523
- var program = new Command();
524
- program.name("jig").description("A design system for coding agents.").version(version);
525
- program.command("install").description("Install Jig rules and the agent skill file into a repository.").requiredOption("--agent <name>", `target agent (${adapterNames().join(", ")})`).option("--scope <scope>", "project or global", "project").action((opts) => {
526
- if (opts.scope !== "project" && opts.scope !== "global") {
527
- console.error(`Invalid scope '${opts.scope}'. Use 'project' or 'global'.`);
528
- process.exit(1);
976
+ // src/commands/explain.ts
977
+ import { join as join12 } from "path";
978
+
979
+ // src/rules/load.ts
980
+ import { readFileSync as readFileSync7, readdirSync as readdirSync5 } from "fs";
981
+ import { join as join10 } from "path";
982
+
983
+ // src/rules/parse.ts
984
+ var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
985
+ function parseRules(markdown, sourceFile) {
986
+ const lines = markdown.split("\n");
987
+ const rules = [];
988
+ let current = null;
989
+ const push = () => {
990
+ if (current) rules.push(current);
991
+ };
992
+ for (const line of lines) {
993
+ const heading = HEADING.exec(line);
994
+ if (heading) {
995
+ push();
996
+ const [, section, num, title] = heading;
997
+ const id = `${section}-${num}`;
998
+ current = {
999
+ id,
1000
+ section,
1001
+ number: Number(num),
1002
+ title,
1003
+ wrong: "",
1004
+ correction: "",
1005
+ source: `${sourceFile}#${id.toLowerCase()}`
1006
+ };
1007
+ continue;
1008
+ }
1009
+ if (!current) continue;
1010
+ if (line.startsWith("\u274C") && !current.wrong) {
1011
+ current.wrong = line.slice(1).trim();
1012
+ } else if (line.startsWith("\u2705") && !current.correction) {
1013
+ current.correction = line.slice(1).trim();
1014
+ }
529
1015
  }
530
- const projectRoot = findProjectRoot(process.cwd());
1016
+ push();
1017
+ return rules;
1018
+ }
1019
+
1020
+ // src/rules/schema.ts
1021
+ var BUCKETS = ["mechanical", "judgment", "hybrid"];
1022
+ var SEVERITIES = ["error", "warning", "note"];
1023
+ function validateIndex(entries) {
1024
+ if (!Array.isArray(entries)) throw new Error("rules.index.json must be an array");
1025
+ return entries.map((raw, i) => {
1026
+ const e = raw;
1027
+ const at = `rules.index.json[${i}]`;
1028
+ if (typeof e.id !== "string" || !/^[A-Z]-\d+$/.test(e.id)) {
1029
+ throw new Error(`${at}: missing or malformed id`);
1030
+ }
1031
+ if (!BUCKETS.includes(e.bucket)) {
1032
+ throw new Error(`${at} (${e.id}): bucket must be one of ${BUCKETS.join(", ")}`);
1033
+ }
1034
+ if (!SEVERITIES.includes(e.severity)) {
1035
+ throw new Error(`${at} (${e.id}): severity must be one of ${SEVERITIES.join(", ")}`);
1036
+ }
1037
+ if (typeof e.since !== "string") {
1038
+ throw new Error(`${at} (${e.id}): since is required`);
1039
+ }
1040
+ return {
1041
+ id: e.id,
1042
+ bucket: e.bucket,
1043
+ severity: e.severity,
1044
+ detector: typeof e.detector === "string" ? e.detector : void 0,
1045
+ fix: typeof e.fix === "string" ? e.fix : void 0,
1046
+ since: e.since
1047
+ };
1048
+ });
1049
+ }
1050
+
1051
+ // src/rules/load.ts
1052
+ function loadRules(rulesDir, indexPath) {
1053
+ const files = readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort();
1054
+ const parsed = [];
1055
+ for (const file of files) {
1056
+ parsed.push(...parseRules(readFileSync7(join10(rulesDir, file), "utf8"), file));
1057
+ }
1058
+ const entries = validateIndex(JSON.parse(readFileSync7(indexPath, "utf8")));
1059
+ const byId = new Map(entries.map((e) => [e.id, e]));
1060
+ const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
1061
+ if (missing.length) {
1062
+ throw new Error(
1063
+ `Rules present in markdown but missing from rules.index.json: ${missing.join(", ")}`
1064
+ );
1065
+ }
1066
+ const parsedIds = new Set(parsed.map((r) => r.id));
1067
+ const orphans = entries.filter((e) => !parsedIds.has(e.id)).map((e) => e.id);
1068
+ if (orphans.length) {
1069
+ throw new Error(
1070
+ `Entries in rules.index.json with no matching rule: ${orphans.join(", ")}`
1071
+ );
1072
+ }
1073
+ return parsed.map((r) => {
1074
+ const { id: _id, ...meta } = byId.get(r.id);
1075
+ return { ...r, ...meta };
1076
+ });
1077
+ }
1078
+
1079
+ // src/rules/specs.ts
1080
+ import { readFileSync as readFileSync8, readdirSync as readdirSync6 } from "fs";
1081
+ import { join as join11 } from "path";
1082
+ var HEADING2 = /^##\s+([PM]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
1083
+ function parseSpecs(markdown, sourceFile) {
1084
+ const lines = markdown.split("\n");
1085
+ const specs = [];
1086
+ let current = null;
1087
+ const body = [];
1088
+ const push = () => {
1089
+ if (!current) return;
1090
+ specs.push({ ...current, body: body.join("\n").replace(/\n{3,}/g, "\n\n").trim() });
1091
+ body.length = 0;
1092
+ };
1093
+ for (const line of lines) {
1094
+ const heading = HEADING2.exec(line);
1095
+ if (heading) {
1096
+ push();
1097
+ const [, id, title] = heading;
1098
+ current = { id, title, body: "", source: `${sourceFile}#${id.toLowerCase()}` };
1099
+ continue;
1100
+ }
1101
+ if (current && /^##\s/.test(line)) {
1102
+ push();
1103
+ current = null;
1104
+ continue;
1105
+ }
1106
+ if (current) body.push(line);
1107
+ }
1108
+ push();
1109
+ return specs;
1110
+ }
1111
+ function loadSpecs(rulesDir) {
1112
+ const specs = [];
1113
+ for (const file of readdirSync6(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1114
+ specs.push(...parseSpecs(readFileSync8(join11(rulesDir, file), "utf8"), file));
1115
+ }
1116
+ return specs;
1117
+ }
1118
+
1119
+ // src/commands/explain.ts
1120
+ var ID_SHAPE = /^([A-Z])-(\d+)$/;
1121
+ function explain(opts) {
1122
+ const raw = opts.ruleId.trim().toUpperCase();
1123
+ const shape = ID_SHAPE.exec(raw);
1124
+ if (!shape) {
1125
+ throw new Error(
1126
+ `'${opts.ruleId.trim()}' is not a rule id. Ids look like 'C-19' or 'P-02' \u2014 a section letter, a hyphen, and a number.`
1127
+ );
1128
+ }
1129
+ const root = opts.packageRoot ?? assetRoot();
1130
+ const rulesDir = join12(root, "rules");
1131
+ const rules = loadRules(rulesDir, join12(root, "rules.index.json"));
1132
+ const rule = rules.find((r) => r.id === raw);
1133
+ if (rule) {
1134
+ const lines = [
1135
+ `${rule.id} ${rule.title}`,
1136
+ "",
1137
+ `\u274C ${rule.wrong}`,
1138
+ `\u2705 ${rule.correction}`,
1139
+ "",
1140
+ ` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
1141
+ ` since ${rule.since} \xB7 ${rule.source}`
1142
+ ];
1143
+ return lines.join("\n");
1144
+ }
1145
+ const spec = loadSpecs(rulesDir).find((s) => s.id === raw);
1146
+ if (spec) {
1147
+ return [
1148
+ `${spec.id} ${spec.title}`,
1149
+ "",
1150
+ spec.body,
1151
+ "",
1152
+ ` specification \u2014 component anatomy and behaviour, not a single rule,`,
1153
+ ` so it carries no \u274C/\u2705 pair and no detector.`,
1154
+ ` ${spec.source}`
1155
+ ].join("\n");
1156
+ }
1157
+ const section = shape[1];
1158
+ const siblings = [
1159
+ ...rules.filter((r) => r.id.startsWith(`${section}-`)).map((r) => r.id),
1160
+ ...loadSpecs(rulesDir).filter((s) => s.id.startsWith(`${section}-`)).map((s) => s.id)
1161
+ ].sort((a, b) => Number(a.split("-")[1]) - Number(b.split("-")[1]));
1162
+ throw new Error(
1163
+ siblings.length > 0 ? `No rule '${raw}'. Section ${section} has: ${siblings.join(", ")}.` : `No rule '${raw}', and no section '${section}' exists.`
1164
+ );
1165
+ }
1166
+
1167
+ // src/commands/check.ts
1168
+ import { existsSync as existsSync9, readFileSync as readFileSync11 } from "fs";
1169
+ import { join as join16 } from "path";
1170
+
1171
+ // src/check/files.ts
1172
+ import { execFileSync } from "child_process";
1173
+ import { readdirSync as readdirSync7, realpathSync, statSync } from "fs";
1174
+ import { join as join13, relative, sep } from "path";
1175
+ var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
1176
+ "node_modules",
1177
+ "dist",
1178
+ ".git",
1179
+ ".jig",
1180
+ ".next",
1181
+ "build",
1182
+ "out",
1183
+ "coverage",
1184
+ ".svelte-kit",
1185
+ "vendor"
1186
+ ]);
1187
+ function isGitRepo(root) {
531
1188
  try {
532
- const result = install({
533
- agent: opts.agent,
534
- scope: opts.scope,
535
- projectRoot,
536
- packageRoot: assetRoot(),
537
- version,
538
- homeDir: homedir()
1189
+ const out = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
1190
+ cwd: root,
1191
+ encoding: "utf8",
1192
+ stdio: ["ignore", "pipe", "ignore"]
539
1193
  });
540
- console.log(`Installed Jig v${version} for ${opts.agent} (${opts.scope} scope)`);
541
- for (const f of result.written) console.log(` + ${f}`);
542
- for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
1194
+ return out.trim() === "true";
1195
+ } catch {
1196
+ return false;
1197
+ }
1198
+ }
1199
+ function gitChangedFiles(root) {
1200
+ let diffOut;
1201
+ try {
1202
+ diffOut = execFileSync(
1203
+ "git",
1204
+ ["-c", "core.quotePath=false", "diff", "--name-only", "--relative", "-z", "HEAD"],
1205
+ { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
1206
+ );
1207
+ } catch {
1208
+ diffOut = "";
1209
+ }
1210
+ let untrackedOut;
1211
+ try {
1212
+ untrackedOut = execFileSync(
1213
+ "git",
1214
+ ["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard", "-z"],
1215
+ { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
1216
+ );
1217
+ } catch {
1218
+ untrackedOut = "";
1219
+ }
1220
+ const files = /* @__PURE__ */ new Set();
1221
+ for (const entry of [...diffOut.split("\0"), ...untrackedOut.split("\0")]) {
1222
+ const f = entry.trim();
1223
+ if (f) files.add(f);
1224
+ }
1225
+ return [...files];
1226
+ }
1227
+ function isExcluded(relPath) {
1228
+ return relPath.split("/").some((segment) => EXCLUDE_DIRS.has(segment));
1229
+ }
1230
+ function walk(root, dir, out, seen) {
1231
+ let entries;
1232
+ try {
1233
+ entries = readdirSync7(dir, { withFileTypes: true });
1234
+ } catch {
1235
+ return;
1236
+ }
1237
+ for (const entry of entries) {
1238
+ if (EXCLUDE_DIRS.has(entry.name)) continue;
1239
+ const abs = join13(dir, entry.name);
1240
+ let isDir = entry.isDirectory();
1241
+ let isFile = entry.isFile();
1242
+ if (entry.isSymbolicLink()) {
1243
+ try {
1244
+ const target = statSync(abs);
1245
+ isDir = target.isDirectory();
1246
+ isFile = target.isFile();
1247
+ } catch {
1248
+ continue;
1249
+ }
1250
+ }
1251
+ if (isDir) {
1252
+ let real;
1253
+ try {
1254
+ real = realpathSync(abs);
1255
+ } catch {
1256
+ continue;
1257
+ }
1258
+ if (seen.has(real)) continue;
1259
+ seen.add(real);
1260
+ walk(root, abs, out, seen);
1261
+ } else if (isFile) {
1262
+ out.push(relative(root, abs).split(sep).join("/"));
1263
+ }
1264
+ }
1265
+ }
1266
+ function filterGitIgnored(root, files) {
1267
+ if (files.length === 0) return files;
1268
+ const parseIgnored = (out) => new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
1269
+ try {
1270
+ const out = execFileSync("git", ["check-ignore", "--stdin"], {
1271
+ cwd: root,
1272
+ input: `${files.join("\n")}
1273
+ `,
1274
+ encoding: "utf8",
1275
+ stdio: ["pipe", "pipe", "ignore"]
1276
+ });
1277
+ const ignored = parseIgnored(out);
1278
+ return files.filter((f) => !ignored.has(f));
543
1279
  } catch (err) {
544
- console.error(err.message);
545
- process.exit(1);
1280
+ const status = err.status;
1281
+ if (status === 1) {
1282
+ const stdout = err.stdout;
1283
+ const out = typeof stdout === "string" ? stdout : stdout?.toString("utf8") ?? "";
1284
+ const ignored = parseIgnored(out);
1285
+ return files.filter((f) => !ignored.has(f));
1286
+ }
1287
+ return files;
546
1288
  }
547
- });
548
- program.command("update").description("Update vendored Jig rules, skipping files you have edited.").action(() => {
549
- const projectRoot = findProjectRoot(process.cwd());
1289
+ }
1290
+ function safeRealpath(p) {
550
1291
  try {
551
- const result = update({
1292
+ return realpathSync(p);
1293
+ } catch {
1294
+ return p;
1295
+ }
1296
+ }
1297
+ function wholeRepoFiles(root) {
1298
+ const out = [];
1299
+ walk(root, root, out, /* @__PURE__ */ new Set([safeRealpath(root)]));
1300
+ return isGitRepo(root) ? filterGitIgnored(root, out) : out;
1301
+ }
1302
+ function selectFiles(root, all) {
1303
+ if (all || !isGitRepo(root)) return { files: wholeRepoFiles(root), mode: "all" };
1304
+ const changed = gitChangedFiles(root).filter((f) => !isExcluded(f));
1305
+ if (changed.length === 0) return { files: wholeRepoFiles(root), mode: "all" };
1306
+ return { files: changed, mode: "changed" };
1307
+ }
1308
+
1309
+ // src/check/report.ts
1310
+ var MAX_ROWS_PER_RULE_PER_FILE = 3;
1311
+ var SEVERITY_SYMBOL = { error: "\u2717", warning: "\u26A0", note: "\xB7" };
1312
+ function plural(n, word) {
1313
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
1314
+ }
1315
+ function formatReport(findings, meta) {
1316
+ const lines = [];
1317
+ const seenRule = /* @__PURE__ */ new Set();
1318
+ const rows = findings.map((f) => {
1319
+ const symbol = SEVERITY_SYMBOL[f.severity] ?? "\xB7";
1320
+ const loc = `${f.file}:${f.line}`;
1321
+ const firstOccurrence = !seenRule.has(f.ruleId);
1322
+ seenRule.add(f.ruleId);
1323
+ const hint = firstOccurrence ? ` (see rule ${f.ruleId} in your installed jig skill's rules/)` : "";
1324
+ return { symbol, ruleId: f.ruleId, message: f.message, loc, fileOnly: f.file, bucket: f.bucket, hint };
1325
+ });
1326
+ if (rows.length > 0) {
1327
+ const msgW = Math.max(0, ...rows.map((r) => r.message.length));
1328
+ const locW = Math.max(0, ...rows.map((r) => r.loc.length));
1329
+ const shown = /* @__PURE__ */ new Map();
1330
+ const suppressed = /* @__PURE__ */ new Map();
1331
+ for (const r of rows) {
1332
+ const key = `${r.ruleId}\0${r.fileOnly}`;
1333
+ const n = (shown.get(key) ?? 0) + 1;
1334
+ shown.set(key, n);
1335
+ if (n <= MAX_ROWS_PER_RULE_PER_FILE) {
1336
+ lines.push(
1337
+ ` ${r.symbol} ${r.ruleId.padEnd(6)}${r.message.padEnd(msgW)} ${r.loc.padEnd(locW)} [${r.bucket}]${r.hint}`
1338
+ );
1339
+ } else {
1340
+ suppressed.set(key, (suppressed.get(key) ?? 0) + 1);
1341
+ }
1342
+ }
1343
+ for (const [key, n] of suppressed) {
1344
+ const [ruleId, file] = key.split("\0");
1345
+ lines.push(` \u2026 ${n} more ${ruleId} in ${file}. Run with --json for all of them.`);
1346
+ }
1347
+ lines.push("");
1348
+ } else {
1349
+ lines.push(" No findings.");
1350
+ lines.push("");
1351
+ if (meta.noTokenLayer) {
1352
+ lines.push(" No file references a Jig token, so H-47 (hard-coded values) was not run.");
1353
+ lines.push(" Run 'jig init' to generate a brand file and wire it (with a mode file) into your CSS.");
1354
+ lines.push("");
1355
+ }
1356
+ }
1357
+ const errors = findings.filter((f) => f.severity === "error").length;
1358
+ const warnings = findings.filter((f) => f.severity === "warning").length;
1359
+ const notes = findings.filter((f) => f.severity === "note").length;
1360
+ const summaryParts = [plural(errors, "error")];
1361
+ if (warnings > 0) summaryParts.push(plural(warnings, "warning"));
1362
+ if (notes > 0) summaryParts.push(plural(notes, "note"));
1363
+ const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
1364
+ const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
1365
+ lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}, ${rulesFired} fired`);
1366
+ if (meta.unscanned && meta.unscanned.count > 0) {
1367
+ const exts = meta.unscanned.extensions.join(", ");
1368
+ lines.push("");
1369
+ lines.push(
1370
+ ` ${meta.unscanned.count} file(s) were not scanned (${exts}) \u2014 no style extraction exists for them, so any CSS they carry is invisible to this check and a clean result above does not cover it.`
1371
+ );
1372
+ }
1373
+ const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
1374
+ const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
1375
+ lines.push(
1376
+ ` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run`
1377
+ );
1378
+ return lines.join("\n");
1379
+ }
1380
+
1381
+ // src/check/token-layer.ts
1382
+ var IMPORT_RE = /@import\s+(?:url\()?["']?[^"')]*\.jig\/tokens\/[^"')]+\.css["']?\)?/i;
1383
+ var VAR_RE = /var\(\s*--([\w-]+)/g;
1384
+ var THEME_DECL_RE = /@theme\b[^{]*\{([\s\S]*?)\}/gi;
1385
+ var CUSTOM_PROP_RE = /--([\w-]+)\s*:/g;
1386
+ function participatesInTokenLayer(source, tokens) {
1387
+ if (IMPORT_RE.test(source)) return true;
1388
+ VAR_RE.lastIndex = 0;
1389
+ let m;
1390
+ while (m = VAR_RE.exec(source)) {
1391
+ if (Object.prototype.hasOwnProperty.call(tokens, m[1])) return true;
1392
+ }
1393
+ THEME_DECL_RE.lastIndex = 0;
1394
+ let theme;
1395
+ while (theme = THEME_DECL_RE.exec(source)) {
1396
+ if (IMPORT_RE.test(theme[1])) return true;
1397
+ CUSTOM_PROP_RE.lastIndex = 0;
1398
+ let prop;
1399
+ while (prop = CUSTOM_PROP_RE.exec(theme[1])) {
1400
+ if (Object.prototype.hasOwnProperty.call(tokens, prop[1])) return true;
1401
+ }
1402
+ }
1403
+ return false;
1404
+ }
1405
+
1406
+ // src/check/ext.ts
1407
+ var CSS_EXTENSIONS = [".css", ".scss", ".less"];
1408
+ function hasExtension(file, exts) {
1409
+ const lower = file.toLowerCase();
1410
+ return exts.some((e) => lower.endsWith(e));
1411
+ }
1412
+ var STYLE_HOST_EXTENSIONS = [
1413
+ ".html",
1414
+ ".htm",
1415
+ ".vue",
1416
+ ".svelte",
1417
+ ".astro",
1418
+ ".jsx",
1419
+ ".tsx",
1420
+ ".js",
1421
+ ".ts",
1422
+ ".mjs",
1423
+ ".cjs",
1424
+ ".mts",
1425
+ ".cts",
1426
+ ".php",
1427
+ ".erb",
1428
+ ".twig",
1429
+ ".hbs",
1430
+ ".mdx",
1431
+ // Server-rendered markup. Every one of these is HTML with a template syntax
1432
+ // threaded through it, so its `<style>` blocks and `style` attributes are the
1433
+ // same constructs already handled — the template tags sit outside the style
1434
+ // regions and are blanked with the rest of the markup.
1435
+ ".asp",
1436
+ ".aspx",
1437
+ ".ascx",
1438
+ ".master",
1439
+ // Classic ASP, ASP.NET
1440
+ ".cshtml",
1441
+ ".vbhtml",
1442
+ ".razor",
1443
+ // Razor
1444
+ ".jsp",
1445
+ ".jspx",
1446
+ // Java
1447
+ ".eex",
1448
+ ".heex",
1449
+ ".leex",
1450
+ // Phoenix
1451
+ ".ejs",
1452
+ ".njk",
1453
+ ".liquid",
1454
+ ".mustache",
1455
+ // Node/Jinja-family engines
1456
+ ".vm",
1457
+ ".ftl",
1458
+ // Velocity, FreeMarker
1459
+ ".jinja",
1460
+ ".jinja2",
1461
+ ".j2",
1462
+ // Jinja
1463
+ // Indentation-delimited templates. These carry no HTML-shaped constructs at
1464
+ // all — their style regions are found by their own block markers and
1465
+ // attribute syntax instead. See `INDENTED_SYNTAXES` in styles.ts.
1466
+ ".pug",
1467
+ ".jade",
1468
+ ".haml",
1469
+ ".slim"
1470
+ ];
1471
+ function isStyleBearing(file) {
1472
+ return hasExtension(file, CSS_EXTENSIONS) || hasExtension(file, STYLE_HOST_EXTENSIONS);
1473
+ }
1474
+
1475
+ // src/check/run.ts
1476
+ import { readFileSync as readFileSync9 } from "fs";
1477
+ import { join as join14 } from "path";
1478
+
1479
+ // src/check/css.ts
1480
+ function maskComments(source) {
1481
+ let out = "";
1482
+ let i = 0;
1483
+ while (i < source.length) {
1484
+ const ch = source[i];
1485
+ if (ch === '"' || ch === "'") {
1486
+ const quote = ch;
1487
+ out += ch;
1488
+ i++;
1489
+ while (i < source.length) {
1490
+ if (source[i] === "\\" && i + 1 < source.length) {
1491
+ out += source[i] + source[i + 1];
1492
+ i += 2;
1493
+ continue;
1494
+ }
1495
+ out += source[i];
1496
+ if (source[i] === quote) {
1497
+ i++;
1498
+ break;
1499
+ }
1500
+ i++;
1501
+ }
1502
+ continue;
1503
+ }
1504
+ if (ch === "/" && source[i + 1] === "*") {
1505
+ const end = source.indexOf("*/", i + 2);
1506
+ const stop = end === -1 ? source.length : end + 2;
1507
+ for (let j = i; j < stop; j++) out += source[j] === "\n" ? "\n" : " ";
1508
+ i = stop;
1509
+ continue;
1510
+ }
1511
+ out += ch;
1512
+ i++;
1513
+ }
1514
+ return out;
1515
+ }
1516
+ function splitRuleBlocks(source) {
1517
+ source = maskComments(source);
1518
+ const blocks = [];
1519
+ const stack = [];
1520
+ let line = 1;
1521
+ let lastBoundary = 0;
1522
+ for (let i = 0; i < source.length; i++) {
1523
+ const ch = source[i];
1524
+ if (ch === "\n") line++;
1525
+ if (ch === "{") {
1526
+ stack.push({ selectorStart: lastBoundary, bodyStart: i + 1, bodyStartLine: line });
1527
+ lastBoundary = i + 1;
1528
+ } else if (ch === "}") {
1529
+ const top = stack.pop();
1530
+ if (top) {
1531
+ blocks.push({
1532
+ selector: cleanSelector(source.slice(top.selectorStart, top.bodyStart - 1)),
1533
+ body: source.slice(top.bodyStart, i),
1534
+ bodyStartLine: top.bodyStartLine
1535
+ });
1536
+ }
1537
+ lastBoundary = i + 1;
1538
+ }
1539
+ }
1540
+ return blocks;
1541
+ }
1542
+ function blankNested(body) {
1543
+ const out = body.split("");
1544
+ const blank2 = (from, to) => {
1545
+ for (let j = from; j < to; j++) if (out[j] !== "\n") out[j] = " ";
1546
+ };
1547
+ let depth = 0;
1548
+ let segmentStart = 0;
1549
+ for (let i = 0; i < body.length; i++) {
1550
+ const ch = body[i];
1551
+ if (ch === "{") {
1552
+ if (depth === 0) blank2(segmentStart, i);
1553
+ depth++;
1554
+ out[i] = " ";
1555
+ } else if (ch === "}") {
1556
+ depth = Math.max(0, depth - 1);
1557
+ out[i] = " ";
1558
+ if (depth === 0) segmentStart = i + 1;
1559
+ } else if (depth > 0) {
1560
+ if (ch !== "\n") out[i] = " ";
1561
+ } else if (ch === ";") {
1562
+ segmentStart = i + 1;
1563
+ }
1564
+ }
1565
+ return out.join("");
1566
+ }
1567
+ function cleanSelector(raw) {
1568
+ const cut = Math.max(raw.lastIndexOf(";"), raw.lastIndexOf("}"));
1569
+ return (cut === -1 ? raw : raw.slice(cut + 1)).trim();
1570
+ }
1571
+ function leafBlocks(source) {
1572
+ return splitRuleBlocks(source).map((b) => b.body.includes("{") ? { ...b, body: blankNested(b.body) } : b).filter((b) => b.body.trim() !== "");
1573
+ }
1574
+ function lineOfOffset(block, offsetInBody) {
1575
+ let n = 0;
1576
+ const end = Math.min(offsetInBody, block.body.length);
1577
+ for (let i = 0; i < end; i++) {
1578
+ if (block.body[i] === "\n") n++;
1579
+ }
1580
+ return block.bodyStartLine + n;
1581
+ }
1582
+ function buildLineIndex(source) {
1583
+ const starts = [0];
1584
+ for (let i = 0; i < source.length; i++) {
1585
+ if (source[i] === "\n") starts.push(i + 1);
1586
+ }
1587
+ return starts;
1588
+ }
1589
+ function lineForOffset(starts, offset) {
1590
+ let lo = 0, hi = starts.length - 1;
1591
+ while (lo < hi) {
1592
+ const mid = lo + hi + 1 >> 1;
1593
+ if (starts[mid] <= offset) lo = mid;
1594
+ else hi = mid - 1;
1595
+ }
1596
+ return lo + 1;
1597
+ }
1598
+ function sourceLine(source, line) {
1599
+ const lines = source.split("\n");
1600
+ return (lines[line - 1] ?? "").trim();
1601
+ }
1602
+
1603
+ // src/check/styles.ts
1604
+ function isStyleHost(file) {
1605
+ return hasExtension(file, STYLE_HOST_EXTENSIONS);
1606
+ }
1607
+ function isMarkupHost(file) {
1608
+ return hasExtension(file, MARKUP_HOSTS);
1609
+ }
1610
+ var MARKUP_HOSTS = [
1611
+ ".html",
1612
+ ".htm",
1613
+ ".vue",
1614
+ ".svelte",
1615
+ ".astro",
1616
+ ".php",
1617
+ ".erb",
1618
+ ".twig",
1619
+ ".hbs",
1620
+ ".mdx",
1621
+ ".jsx",
1622
+ ".tsx",
1623
+ ".asp",
1624
+ ".aspx",
1625
+ ".ascx",
1626
+ ".master",
1627
+ ".cshtml",
1628
+ ".vbhtml",
1629
+ ".razor",
1630
+ ".jsp",
1631
+ ".jspx",
1632
+ ".eex",
1633
+ ".heex",
1634
+ ".leex",
1635
+ ".ejs",
1636
+ ".njk",
1637
+ ".liquid",
1638
+ ".mustache",
1639
+ ".vm",
1640
+ ".ftl",
1641
+ ".jinja",
1642
+ ".jinja2",
1643
+ ".j2"
1644
+ ];
1645
+ var INDENTED_SYNTAXES = {
1646
+ ".pug": { blockMarker: /^(\s*)style\.?\s*$/, attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g },
1647
+ ".jade": { blockMarker: /^(\s*)style\.?\s*$/, attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g },
1648
+ ".haml": {
1649
+ blockMarker: /^(\s*)(?::css|%style)\s*$/,
1650
+ attr: /(?::style\s*=>|\bstyle\s*:)\s*(["'])([\s\S]*?)\1/g
1651
+ },
1652
+ ".slim": {
1653
+ blockMarker: /^(\s*)(?:css:|style:)\s*$/,
1654
+ attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g
1655
+ }
1656
+ };
1657
+ function indentedSyntaxFor(file) {
1658
+ const lower = file.toLowerCase();
1659
+ const ext = Object.keys(INDENTED_SYNTAXES).find((e) => lower.endsWith(e));
1660
+ return ext ? INDENTED_SYNTAXES[ext] : void 0;
1661
+ }
1662
+ function indentedSpans(source, syntax) {
1663
+ const spans = [];
1664
+ const lines = source.split("\n");
1665
+ const offsets = [];
1666
+ let at = 0;
1667
+ for (const line of lines) {
1668
+ offsets.push(at);
1669
+ at += line.length + 1;
1670
+ }
1671
+ for (let i = 0; i < lines.length; i++) {
1672
+ const marker = syntax.blockMarker.exec(lines[i]);
1673
+ if (!marker) continue;
1674
+ const markerIndent = marker[1].length;
1675
+ let end = i;
1676
+ for (let j = i + 1; j < lines.length; j++) {
1677
+ if (lines[j].trim() === "") {
1678
+ end = j;
1679
+ continue;
1680
+ }
1681
+ const indent = lines[j].length - lines[j].trimStart().length;
1682
+ if (indent <= markerIndent) break;
1683
+ end = j;
1684
+ }
1685
+ if (end === i) continue;
1686
+ const start = offsets[i + 1];
1687
+ const last = offsets[end] + lines[end].length;
1688
+ spans.push({ start, end: last });
1689
+ i = end;
1690
+ }
1691
+ return spans;
1692
+ }
1693
+ var STYLE_BLOCK = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
1694
+ var STYLE_ATTR = /\bstyle\s*=\s*(["'])([\s\S]*?)\1/gi;
1695
+ var CSS_TAG = /\b(?:styled(?:\.\w+|\([^)]*\))(?:\s*\.\w+\([^)]*\))*|css|createGlobalStyle|keyframes|injectGlobal)\s*`/g;
1696
+ function readTemplate(source, openBacktick) {
1697
+ let i = openBacktick + 1;
1698
+ const start = i;
1699
+ while (i < source.length) {
1700
+ const ch = source[i];
1701
+ if (ch === "\\") {
1702
+ i += 2;
1703
+ continue;
1704
+ }
1705
+ if (ch === "`") return { start, end: i };
1706
+ if (ch === "$" && source[i + 1] === "{") {
1707
+ let depth = 1;
1708
+ i += 2;
1709
+ while (i < source.length && depth > 0) {
1710
+ if (source[i] === "\\") {
1711
+ i += 2;
1712
+ continue;
1713
+ }
1714
+ if (source[i] === "{") depth++;
1715
+ else if (source[i] === "}") depth--;
1716
+ else if (source[i] === "`") {
1717
+ const inner = readTemplate(source, i);
1718
+ if (!inner) return null;
1719
+ i = inner.end;
1720
+ }
1721
+ i++;
1722
+ }
1723
+ continue;
1724
+ }
1725
+ i++;
1726
+ }
1727
+ return null;
1728
+ }
1729
+ var STYLE_OBJECT = /\bstyle\s*=\s*\{\{/g;
1730
+ function readObject(source, open) {
1731
+ let depth = 0;
1732
+ let i = open + 1;
1733
+ const start = open + 2;
1734
+ while (i < source.length) {
1735
+ const ch = source[i];
1736
+ if (ch === '"' || ch === "'" || ch === "`") {
1737
+ const quote = ch;
1738
+ i++;
1739
+ while (i < source.length && source[i] !== quote) {
1740
+ if (source[i] === "\\") i++;
1741
+ i++;
1742
+ }
1743
+ i++;
1744
+ continue;
1745
+ }
1746
+ if (ch === "{") depth++;
1747
+ else if (ch === "}") {
1748
+ depth--;
1749
+ if (depth === 0) return { start, end: i };
1750
+ }
1751
+ i++;
1752
+ }
1753
+ return null;
1754
+ }
1755
+ function styleObjectToCss(body) {
1756
+ let out = "";
1757
+ for (const ch of body) {
1758
+ if (ch === '"' || ch === "'" || ch === "`") out += " ";
1759
+ else if (ch === ",") out += ";";
1760
+ else out += ch;
1761
+ }
1762
+ return out;
1763
+ }
1764
+ function maskNonStyleRegions(source, file) {
1765
+ if (hasExtension(file, CSS_EXTENSIONS)) return source;
1766
+ if (!isStyleHost(file)) return blank(source);
1767
+ const spans = [];
1768
+ const indented = indentedSyntaxFor(file);
1769
+ if (indented) {
1770
+ spans.push(...indentedSpans(source, indented));
1771
+ for (const m of source.matchAll(indented.attr)) {
1772
+ const valueStart = m.index + m[0].indexOf(m[1]) + 1;
1773
+ spans.push({ start: valueStart, end: valueStart + m[2].length, braced: true });
1774
+ }
1775
+ return applySpans(source, spans);
1776
+ }
1777
+ if (hasExtension(file, MARKUP_HOSTS)) {
1778
+ for (const m of source.matchAll(STYLE_BLOCK)) {
1779
+ const bodyStart = m.index + m[0].indexOf(">") + 1;
1780
+ spans.push({ start: bodyStart, end: bodyStart + m[1].length });
1781
+ }
1782
+ for (const m of source.matchAll(STYLE_ATTR)) {
1783
+ const valueStart = m.index + m[0].indexOf(m[1]) + 1;
1784
+ spans.push({ start: valueStart, end: valueStart + m[2].length, braced: true });
1785
+ }
1786
+ }
1787
+ for (const m of source.matchAll(CSS_TAG)) {
1788
+ const backtick = m.index + m[0].length - 1;
1789
+ const span = readTemplate(source, backtick);
1790
+ if (span) spans.push({ ...span, braced: true });
1791
+ }
1792
+ for (const m of source.matchAll(STYLE_OBJECT)) {
1793
+ const span = readObject(source, m.index + m[0].length - 2);
1794
+ if (span) spans.push({ ...span, transform: styleObjectToCss, braced: true });
1795
+ }
1796
+ return applySpans(source, spans);
1797
+ }
1798
+ function applySpans(source, spans) {
1799
+ if (spans.length === 0) return blank(source);
1800
+ const out = blank(source).split("");
1801
+ for (const span of spans) {
1802
+ const text = source.slice(span.start, span.end);
1803
+ const rendered = span.transform ? span.transform(text) : text;
1804
+ for (let i = 0; i < rendered.length; i++) {
1805
+ out[span.start + i] = rendered[i];
1806
+ }
1807
+ if (span.braced && span.start > 0 && span.end < out.length) {
1808
+ out[span.start - 1] = "{";
1809
+ out[span.end] = "}";
1810
+ }
1811
+ }
1812
+ return out.join("");
1813
+ }
1814
+ function blank(source) {
1815
+ let out = "";
1816
+ for (const ch of source) out += ch === "\n" ? "\n" : " ";
1817
+ return out;
1818
+ }
1819
+
1820
+ // src/check/finding.ts
1821
+ function mkFinding(ctx, detector, file, line, message, excerpt) {
1822
+ return {
1823
+ ruleId: ctx.ruleId,
1824
+ detector,
1825
+ bucket: ctx.bucket,
1826
+ severity: ctx.severity,
1827
+ file,
1828
+ line,
1829
+ message,
1830
+ excerpt
1831
+ };
1832
+ }
1833
+
1834
+ // src/check/detectors/gradient-text.ts
1835
+ var CLIP_RE = /(?:-webkit-)?background-clip\s*:\s*text\b/i;
1836
+ var GRADIENT_RE = /(?:background|background-image)\s*:[^;]*(?:linear|radial|conic)-gradient\s*\(/i;
1837
+ var gradientText = {
1838
+ name: "gradient-text",
1839
+ appliesTo: (file) => isStyleBearing(file),
1840
+ run(source, file, ctx) {
1841
+ const findings = [];
1842
+ for (const block of leafBlocks(source)) {
1843
+ const clipMatch = CLIP_RE.exec(block.body);
1844
+ if (!clipMatch) continue;
1845
+ if (!GRADIENT_RE.test(block.body)) continue;
1846
+ const line = lineOfOffset(block, clipMatch.index);
1847
+ findings.push(
1848
+ mkFinding(
1849
+ ctx,
1850
+ "gradient-text",
1851
+ file,
1852
+ line,
1853
+ "Gradient text via background-clip: text \u2014 use solid --color-text-strong instead",
1854
+ sourceLine(source, line)
1855
+ )
1856
+ );
1857
+ }
1858
+ return findings;
1859
+ }
1860
+ };
1861
+
1862
+ // src/check/color.ts
1863
+ var NAMED = {
1864
+ white: { r: 255, g: 255, b: 255 },
1865
+ black: { r: 0, g: 0, b: 0 }
1866
+ };
1867
+ function parseComponent(raw) {
1868
+ const v = raw.trim();
1869
+ if (v.endsWith("%")) return Math.round(parseFloat(v) / 100 * 255);
1870
+ return Math.round(parseFloat(v));
1871
+ }
1872
+ function parseAlpha(raw) {
1873
+ if (raw === void 0 || raw.trim() === "") return 1;
1874
+ const v = raw.trim();
1875
+ return v.endsWith("%") ? parseFloat(v) / 100 : parseFloat(v);
1876
+ }
1877
+ function splitFnArgs(inner) {
1878
+ if (inner.includes(",")) {
1879
+ const parts = inner.split(",").map((p) => p.trim());
1880
+ const alphaStr2 = parts.length === 4 ? parts.pop() : void 0;
1881
+ return { parts, alphaStr: alphaStr2 };
1882
+ }
1883
+ const [main, alphaStr] = inner.split("/").map((p) => p.trim());
1884
+ return { parts: main.split(/\s+/).filter(Boolean), alphaStr };
1885
+ }
1886
+ function parseHex(value) {
1887
+ const m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(value.trim());
1888
+ if (!m) return null;
1889
+ const hex = m[1];
1890
+ let r, g, b, a = 255;
1891
+ if (hex.length === 3 || hex.length === 4) {
1892
+ r = parseInt(hex[0] + hex[0], 16);
1893
+ g = parseInt(hex[1] + hex[1], 16);
1894
+ b = parseInt(hex[2] + hex[2], 16);
1895
+ if (hex.length === 4) a = parseInt(hex[3] + hex[3], 16);
1896
+ } else {
1897
+ r = parseInt(hex.slice(0, 2), 16);
1898
+ g = parseInt(hex.slice(2, 4), 16);
1899
+ b = parseInt(hex.slice(4, 6), 16);
1900
+ if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16);
1901
+ }
1902
+ return { r, g, b, a: a / 255 };
1903
+ }
1904
+ function parseRgbFn(value) {
1905
+ const m = /^rgba?\(([^)]*)\)$/i.exec(value.trim());
1906
+ if (!m) return null;
1907
+ const { parts, alphaStr } = splitFnArgs(m[1].trim());
1908
+ if (parts.length !== 3) return null;
1909
+ const [r, g, b] = parts.map(parseComponent);
1910
+ if ([r, g, b].some((n) => Number.isNaN(n))) return null;
1911
+ return { r, g, b, a: parseAlpha(alphaStr) };
1912
+ }
1913
+ function parseHslFn(value) {
1914
+ const m = /^hsla?\(([^)]*)\)$/i.exec(value.trim());
1915
+ if (!m) return null;
1916
+ const { parts, alphaStr } = splitFnArgs(m[1].trim());
1917
+ if (parts.length !== 3) return null;
1918
+ const h = parseFloat(parts[0]);
1919
+ const s = parseFloat(parts[1]);
1920
+ const l = parseFloat(parts[2]);
1921
+ if ([h, s, l].some((n) => Number.isNaN(n))) return null;
1922
+ return { h: (h % 360 + 360) % 360, s, l, a: parseAlpha(alphaStr) };
1923
+ }
1924
+ function hslToRgb(h, s, l) {
1925
+ const sN = s / 100;
1926
+ const lN = l / 100;
1927
+ const c = (1 - Math.abs(2 * lN - 1)) * sN;
1928
+ const hp = (h % 360 + 360) % 360 / 60;
1929
+ const x = c * (1 - Math.abs(hp % 2 - 1));
1930
+ let r1 = 0, g1 = 0, b1 = 0;
1931
+ if (hp < 1) [r1, g1, b1] = [c, x, 0];
1932
+ else if (hp < 2) [r1, g1, b1] = [x, c, 0];
1933
+ else if (hp < 3) [r1, g1, b1] = [0, c, x];
1934
+ else if (hp < 4) [r1, g1, b1] = [0, x, c];
1935
+ else if (hp < 5) [r1, g1, b1] = [x, 0, c];
1936
+ else [r1, g1, b1] = [c, 0, x];
1937
+ const m = lN - c / 2;
1938
+ return { r: Math.round((r1 + m) * 255), g: Math.round((g1 + m) * 255), b: Math.round((b1 + m) * 255) };
1939
+ }
1940
+ function rgbToHsl(rgb) {
1941
+ const r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255;
1942
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
1943
+ const l = (max + min) / 2;
1944
+ let h = 0, s = 0;
1945
+ if (max !== min) {
1946
+ const d = max - min;
1947
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
1948
+ switch (max) {
1949
+ case r:
1950
+ h = (g - b) / d + (g < b ? 6 : 0);
1951
+ break;
1952
+ case g:
1953
+ h = (b - r) / d + 2;
1954
+ break;
1955
+ default:
1956
+ h = (r - g) / d + 4;
1957
+ break;
1958
+ }
1959
+ h *= 60;
1960
+ }
1961
+ return { h, s: s * 100, l: l * 100 };
1962
+ }
1963
+ function substituteVars(value, tokens, depth = 0) {
1964
+ if (depth > 6) return value;
1965
+ let changed = false;
1966
+ const result = value.replace(/var\(\s*--([\w-]+)\s*(?:,\s*([^)]*))?\)/gi, (match, name, fallback) => {
1967
+ if (Object.prototype.hasOwnProperty.call(tokens, name)) {
1968
+ changed = true;
1969
+ return tokens[name];
1970
+ }
1971
+ if (fallback !== void 0) {
1972
+ changed = true;
1973
+ return fallback;
1974
+ }
1975
+ return match;
1976
+ });
1977
+ return changed ? substituteVars(result, tokens, depth + 1) : result;
1978
+ }
1979
+ var OKLCH_RE = /^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([-\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?)\s*)?\)$/i;
1980
+ function gammaEncode(c) {
1981
+ return c <= 31308e-7 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
1982
+ }
1983
+ function parseOklchFn(value) {
1984
+ const m = OKLCH_RE.exec(value.trim());
1985
+ if (!m) return null;
1986
+ const num = (raw, pctBasis) => raw.endsWith("%") ? Number.parseFloat(raw) / 100 * pctBasis : Number.parseFloat(raw);
1987
+ const L = num(m[1], 1);
1988
+ const C = num(m[2], 0.4);
1989
+ const H = Number.parseFloat(m[3]);
1990
+ const a = m[4] === void 0 ? 1 : Math.min(1, Math.max(0, num(m[4], 1)));
1991
+ if ([L, C, H].some((n) => !Number.isFinite(n))) return null;
1992
+ const hRad = H * Math.PI / 180;
1993
+ const oa = C * Math.cos(hRad);
1994
+ const ob = C * Math.sin(hRad);
1995
+ const l_ = L + 0.3963377774 * oa + 0.2158037573 * ob;
1996
+ const m_ = L - 0.1055613458 * oa - 0.0638541728 * ob;
1997
+ const s_ = L - 0.0894841775 * oa - 1.291485548 * ob;
1998
+ const l = l_ ** 3;
1999
+ const mm = m_ ** 3;
2000
+ const ss = s_ ** 3;
2001
+ const lr = 4.0767416621 * l - 3.3077115913 * mm + 0.2309699292 * ss;
2002
+ const lg = -1.2684380046 * l + 2.6097574011 * mm - 0.3413193965 * ss;
2003
+ const lb = -0.0041960863 * l - 0.7034186147 * mm + 1.707614701 * ss;
2004
+ const to255 = (lin) => Math.round(Math.min(1, Math.max(0, gammaEncode(lin))) * 255);
2005
+ return { r: to255(lr), g: to255(lg), b: to255(lb), a };
2006
+ }
2007
+ function extractColorComponents(value, tokens) {
2008
+ const substituted = substituteVars(value, tokens).trim();
2009
+ if (/var\(/i.test(substituted)) return null;
2010
+ const named = NAMED[substituted.toLowerCase()];
2011
+ if (named) return { rgb: named, hsl: rgbToHsl(named), alpha: 1 };
2012
+ const hex = parseHex(substituted);
2013
+ if (hex) {
2014
+ const rgb = { r: hex.r, g: hex.g, b: hex.b };
2015
+ return { rgb, hsl: rgbToHsl(rgb), alpha: hex.a };
2016
+ }
2017
+ const rgbFn = parseRgbFn(substituted);
2018
+ if (rgbFn) {
2019
+ const rgb = { r: rgbFn.r, g: rgbFn.g, b: rgbFn.b };
2020
+ return { rgb, hsl: rgbToHsl(rgb), alpha: rgbFn.a };
2021
+ }
2022
+ const hslFn = parseHslFn(substituted);
2023
+ if (hslFn) {
2024
+ const rgb = hslToRgb(hslFn.h, hslFn.s, hslFn.l);
2025
+ return { rgb, hsl: { h: hslFn.h, s: hslFn.s, l: hslFn.l }, alpha: hslFn.a };
2026
+ }
2027
+ const oklch = parseOklchFn(substituted);
2028
+ if (oklch) {
2029
+ const rgb = { r: oklch.r, g: oklch.g, b: oklch.b };
2030
+ return { rgb, hsl: rgbToHsl(rgb), alpha: oklch.a };
2031
+ }
2032
+ return null;
2033
+ }
2034
+ var VAR_WITH_FALLBACK_RE = /var\(\s*--[\w-]+\s*,/i;
2035
+ function resolveOpaqueColor(value, tokens) {
2036
+ if (VAR_WITH_FALLBACK_RE.test(value)) return null;
2037
+ const c = extractColorComponents(value, tokens);
2038
+ if (!c || c.alpha < 0.999) return null;
2039
+ return c.rgb;
2040
+ }
2041
+ function relativeLuminance(rgb) {
2042
+ const srgb = [rgb.r, rgb.g, rgb.b].map((v) => {
2043
+ const c = v / 255;
2044
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
2045
+ });
2046
+ return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
2047
+ }
2048
+ function contrastRatio(a, b) {
2049
+ const la = relativeLuminance(a) + 0.05;
2050
+ const lb = relativeLuminance(b) + 0.05;
2051
+ return la > lb ? la / lb : lb / la;
2052
+ }
2053
+
2054
+ // src/check/detectors/backdrop-blur.ts
2055
+ var BLUR_RE = /(?:-webkit-)?backdrop-filter\s*:[^;]*blur\s*\(/i;
2056
+ var BG_DECL_RE = /(?<![-\w])background(?:-color)?\s*:\s*([^;]+);?/gi;
2057
+ function hasTranslucentBackground(body, tokens) {
2058
+ BG_DECL_RE.lastIndex = 0;
2059
+ let m;
2060
+ while (m = BG_DECL_RE.exec(body)) {
2061
+ const c = extractColorComponents(m[1].trim(), tokens);
2062
+ if (c && c.alpha < 0.999) return true;
2063
+ }
2064
+ return false;
2065
+ }
2066
+ var backdropBlur = {
2067
+ name: "backdrop-blur",
2068
+ appliesTo: (file) => isStyleBearing(file),
2069
+ run(source, file, ctx) {
2070
+ const findings = [];
2071
+ for (const block of leafBlocks(source)) {
2072
+ const blurMatch = BLUR_RE.exec(block.body);
2073
+ if (!blurMatch) continue;
2074
+ if (!hasTranslucentBackground(block.body, ctx.tokens)) continue;
2075
+ const line = lineOfOffset(block, blurMatch.index);
2076
+ findings.push(
2077
+ mkFinding(
2078
+ ctx,
2079
+ "backdrop-blur",
2080
+ file,
2081
+ line,
2082
+ "Glassmorphism \u2014 translucent fill + backdrop-filter: blur() fights legibility; use an opaque surface with a --color-stroke-weak edge",
2083
+ sourceLine(source, line)
2084
+ )
2085
+ );
2086
+ }
2087
+ return findings;
2088
+ }
2089
+ };
2090
+
2091
+ // src/check/detectors/pure-black-white.ts
2092
+ var COLOR_DECL_RE = /(?<![-\w])color\s*:\s*([^;]+)(?:;|$)/i;
2093
+ var BG_DECL_RE2 = /(?<![-\w])background(?:-color)?\s*:\s*([^;]+)(?:;|$)/gi;
2094
+ function isPure(rgb, target) {
2095
+ return rgb.r === target && rgb.g === target && rgb.b === target;
2096
+ }
2097
+ var pureBlackWhite = {
2098
+ name: "pure-black-white",
2099
+ appliesTo: (file) => isStyleBearing(file),
2100
+ run(source, file, ctx) {
2101
+ const findings = [];
2102
+ for (const block of leafBlocks(source)) {
2103
+ const colorMatch = COLOR_DECL_RE.exec(block.body);
2104
+ if (!colorMatch) continue;
2105
+ const fg = extractColorComponents(colorMatch[1].trim(), ctx.tokens);
2106
+ if (!fg || fg.alpha < 0.999 || !isPure(fg.rgb, 0)) continue;
2107
+ BG_DECL_RE2.lastIndex = 0;
2108
+ let hasWhiteBackground = false;
2109
+ let m;
2110
+ while (m = BG_DECL_RE2.exec(block.body)) {
2111
+ const bg = extractColorComponents(m[1].trim(), ctx.tokens);
2112
+ if (bg && bg.alpha >= 0.999 && isPure(bg.rgb, 255)) {
2113
+ hasWhiteBackground = true;
2114
+ break;
2115
+ }
2116
+ }
2117
+ if (!hasWhiteBackground) continue;
2118
+ const line = lineOfOffset(block, colorMatch.index);
2119
+ findings.push(
2120
+ mkFinding(
2121
+ ctx,
2122
+ "pure-black-white",
2123
+ file,
2124
+ line,
2125
+ "Pure #000 on pure #fff \u2014 use near-black text on an off-white background instead",
2126
+ sourceLine(source, line)
2127
+ )
2128
+ );
2129
+ }
2130
+ return findings;
2131
+ }
2132
+ };
2133
+
2134
+ // src/check/tailwind.ts
2135
+ var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
2136
+ function readBraced(source, open) {
2137
+ let depth = 0;
2138
+ for (let i = open; i < source.length; i++) {
2139
+ const ch = source[i];
2140
+ if (ch === '"' || ch === "'" || ch === "`") {
2141
+ const quote = ch;
2142
+ i++;
2143
+ while (i < source.length && source[i] !== quote) {
2144
+ if (source[i] === "\\") i++;
2145
+ i++;
2146
+ }
2147
+ continue;
2148
+ }
2149
+ if (ch === "{") depth++;
2150
+ else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
2151
+ }
2152
+ return null;
2153
+ }
2154
+ function classAttributeValues(source) {
2155
+ const out = [];
2156
+ const lineAt = (offset) => source.slice(0, offset).split("\n").length;
2157
+ for (const m of source.matchAll(CLASS_ATTR)) {
2158
+ const at = m.index + m[0].length;
2159
+ const opener = source[at];
2160
+ if (opener === '"' || opener === "'") {
2161
+ const end = source.indexOf(opener, at + 1);
2162
+ if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
2163
+ continue;
2164
+ }
2165
+ if (opener !== "{") continue;
2166
+ const raw = readBraced(source, at);
2167
+ if (raw === null) continue;
2168
+ const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
2169
+ (q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
2170
+ );
2171
+ if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
2172
+ }
2173
+ return out;
2174
+ }
2175
+ var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
2176
+ var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
2177
+ var LENGTH = /^-?\d*\.?\d+(?:px|rem|em|pt|vh|vw|vmin|vmax|ch|ex)$/i;
2178
+ function arbitraryValues(classes) {
2179
+ const out = [];
2180
+ for (const m of classes.matchAll(ARBITRARY)) {
2181
+ const [, utility, rawValue] = m;
2182
+ const value = rawValue.replace(/_/g, " ");
2183
+ if (/var\(|--/.test(value)) continue;
2184
+ if (COLOUR.test(value)) out.push({ utility, value, kind: "colour" });
2185
+ else if (LENGTH.test(value)) out.push({ utility, value, kind: "length" });
2186
+ }
2187
+ return out;
2188
+ }
2189
+ var PALETTE = {
2190
+ white: "#ffffff",
2191
+ black: "#000000",
2192
+ "gray-50": "#f9fafb",
2193
+ "gray-100": "#f3f4f6",
2194
+ "gray-200": "#e5e7eb",
2195
+ "gray-300": "#d1d5db",
2196
+ "gray-400": "#9ca3af",
2197
+ "gray-500": "#6b7280",
2198
+ "gray-600": "#4b5563",
2199
+ "gray-700": "#374151",
2200
+ "gray-800": "#1f2937",
2201
+ "gray-900": "#111827",
2202
+ "gray-950": "#030712",
2203
+ "slate-50": "#f8fafc",
2204
+ "slate-400": "#94a3b8",
2205
+ "slate-500": "#64748b",
2206
+ "slate-800": "#1e293b",
2207
+ "slate-900": "#0f172a",
2208
+ "slate-950": "#020617",
2209
+ "zinc-400": "#a1a1aa",
2210
+ "zinc-500": "#71717a",
2211
+ "zinc-900": "#18181b",
2212
+ "neutral-400": "#a3a3a3",
2213
+ "neutral-500": "#737373",
2214
+ "neutral-900": "#171717",
2215
+ "stone-400": "#a8a29e",
2216
+ "stone-500": "#78716c",
2217
+ "stone-900": "#1c1917"
2218
+ };
2219
+ function palettePairs(classes) {
2220
+ let background;
2221
+ let foreground;
2222
+ for (const token of classes.split(/\s+/)) {
2223
+ const bare = token.replace(/^(?:[\w-]+:)*/, "");
2224
+ const bg = /^bg-(.+)$/.exec(bare);
2225
+ const fg = /^text-(.+)$/.exec(bare);
2226
+ if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
2227
+ if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
2228
+ }
2229
+ return background && foreground ? [{ background, foreground }] : [];
2230
+ }
2231
+
2232
+ // src/check/detectors/contrast-floor.ts
2233
+ var FLOOR = 4.5;
2234
+ var LARGE_FLOOR = 3;
2235
+ var REM_TO_PX = 16;
2236
+ var FONT_SIZE_DECL_RE = /(?<![-\w])font-size\s*:\s*([^;]+?)(?:;|$)/i;
2237
+ var FONT_SIZE_PX_RE = /^(-?\d*\.?\d+)px$/i;
2238
+ var FONT_SIZE_REM_RE = /^(-?\d*\.?\d+)rem$/i;
2239
+ var BOLD_RE = /(?<![-\w])font-weight\s*:\s*(bold|[6-9]\d\d)/i;
2240
+ function resolveFloor(body) {
2241
+ const m = FONT_SIZE_DECL_RE.exec(body);
2242
+ if (!m) return FLOOR;
2243
+ const raw = m[1].trim();
2244
+ const pxMatch = FONT_SIZE_PX_RE.exec(raw);
2245
+ const remMatch = FONT_SIZE_REM_RE.exec(raw);
2246
+ let px;
2247
+ if (pxMatch) px = parseFloat(pxMatch[1]);
2248
+ else if (remMatch) px = parseFloat(remMatch[1]) * REM_TO_PX;
2249
+ else return LARGE_FLOOR;
2250
+ const large = BOLD_RE.test(body) ? px >= 18.66 : px >= 24;
2251
+ return large ? LARGE_FLOOR : FLOOR;
2252
+ }
2253
+ var DECL_RE = /(?<![-\w])(color|background(?:-color)?)\s*:\s*([^;]+)(?:;|$)/gi;
2254
+ var contrastFloor = {
2255
+ name: "contrast-floor",
2256
+ appliesTo: (file) => isStyleBearing(file),
2257
+ run(source, file, ctx) {
2258
+ const findings = [];
2259
+ for (const block of leafBlocks(source)) {
2260
+ let fgValue = null;
2261
+ let fgOffset = -1;
2262
+ let bgValue = null;
2263
+ DECL_RE.lastIndex = 0;
2264
+ let m;
2265
+ while (m = DECL_RE.exec(block.body)) {
2266
+ const prop = m[1].toLowerCase();
2267
+ if (prop === "color") {
2268
+ fgValue = m[2].trim();
2269
+ fgOffset = m.index;
2270
+ } else {
2271
+ bgValue = m[2].trim();
2272
+ }
2273
+ }
2274
+ if (!fgValue || !bgValue) continue;
2275
+ const fg = resolveOpaqueColor(fgValue, ctx.tokens);
2276
+ const bg = resolveOpaqueColor(bgValue, ctx.tokens);
2277
+ if (!fg || !bg) continue;
2278
+ const floor = resolveFloor(block.body);
2279
+ const ratio = contrastRatio(fg, bg);
2280
+ if (ratio >= floor) continue;
2281
+ const line = lineOfOffset(block, fgOffset);
2282
+ findings.push(
2283
+ mkFinding(
2284
+ ctx,
2285
+ "contrast-floor",
2286
+ file,
2287
+ line,
2288
+ `Foreground/background contrast is ${ratio.toFixed(2)}:1, below the ${floor}:1 floor`,
2289
+ sourceLine(source, line)
2290
+ )
2291
+ );
2292
+ }
2293
+ for (const attr of isMarkupHost(file) ? classAttributeValues(ctx.raw) : []) {
2294
+ for (const pair of palettePairs(attr.classes)) {
2295
+ const fg = resolveOpaqueColor(pair.foreground, ctx.tokens);
2296
+ const bg = resolveOpaqueColor(pair.background, ctx.tokens);
2297
+ if (!fg || !bg) continue;
2298
+ const ratio = contrastRatio(fg, bg);
2299
+ if (ratio >= 4.5) continue;
2300
+ findings.push(
2301
+ mkFinding(
2302
+ ctx,
2303
+ "contrast-floor",
2304
+ file,
2305
+ attr.line,
2306
+ `Foreground/background contrast is ${ratio.toFixed(2)}:1, below the 4.5:1 floor (${pair.foreground} on ${pair.background})`,
2307
+ sourceLine(ctx.raw, attr.line)
2308
+ )
2309
+ );
2310
+ }
2311
+ }
2312
+ return findings;
2313
+ }
2314
+ };
2315
+
2316
+ // src/check/detectors/focus-removed.ts
2317
+ var OUTLINE_NONE_RE = /(?<![-\w])outline\s*:\s*(none|0(?:px)?)\s*(?:!important)?\s*(?:;|\}|$)/gi;
2318
+ var FOCUS_VISIBLE_RE = /:focus-visible\b/i;
2319
+ var focusRemoved = {
2320
+ name: "focus-removed",
2321
+ appliesTo: (file) => isStyleBearing(file),
2322
+ run(source, file, ctx) {
2323
+ if (FOCUS_VISIBLE_RE.test(source)) return [];
2324
+ const findings = [];
2325
+ const starts = buildLineIndex(source);
2326
+ OUTLINE_NONE_RE.lastIndex = 0;
2327
+ let m;
2328
+ while (m = OUTLINE_NONE_RE.exec(source)) {
2329
+ const line = lineForOffset(starts, m.index);
2330
+ findings.push(
2331
+ mkFinding(
2332
+ ctx,
2333
+ "focus-removed",
2334
+ file,
2335
+ line,
2336
+ "outline removed with no :focus-visible replacement anywhere in this file",
2337
+ sourceLine(source, line)
2338
+ )
2339
+ );
2340
+ }
2341
+ return findings;
2342
+ }
2343
+ };
2344
+
2345
+ // src/check/detectors/hardcoded-value.ts
2346
+ var COLOR_PROPS = /* @__PURE__ */ new Set([
2347
+ "color",
2348
+ "background-color",
2349
+ "background",
2350
+ "border-color",
2351
+ "border-top-color",
2352
+ "border-right-color",
2353
+ "border-bottom-color",
2354
+ "border-left-color",
2355
+ "fill",
2356
+ "stroke",
2357
+ "outline-color"
2358
+ ]);
2359
+ var SPACING_PROPS = /* @__PURE__ */ new Set([
2360
+ "font-size",
2361
+ "margin",
2362
+ "margin-top",
2363
+ "margin-right",
2364
+ "margin-bottom",
2365
+ "margin-left",
2366
+ "padding",
2367
+ "padding-top",
2368
+ "padding-right",
2369
+ "padding-bottom",
2370
+ "padding-left",
2371
+ "gap",
2372
+ "row-gap",
2373
+ "column-gap"
2374
+ ]);
2375
+ var DECL_RE2 = /(?<![-\w])([a-zA-Z-]+)\s*:\s*([^;]+)(?:;|$)/g;
2376
+ var COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|hsl)a?\([^)]*\)/;
2377
+ var PX_RE = /(-?\d*\.?\d+)px/g;
2378
+ var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
2379
+ var KEYFRAME_STEP_RE = /^(from|to|\d+(\.\d+)?%)$/i;
2380
+ var hardcodedValue = {
2381
+ name: "hardcoded-value",
2382
+ appliesTo: (file) => isStyleBearing(file),
2383
+ run(source, file, ctx) {
2384
+ const participates = isStyleHost(file) ? ctx.projectParticipates : participatesInTokenLayer(source, ctx.tokens);
2385
+ if (!participates) return [];
2386
+ const findings = [];
2387
+ for (const block of leafBlocks(source)) {
2388
+ if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
2389
+ DECL_RE2.lastIndex = 0;
2390
+ let m;
2391
+ while (m = DECL_RE2.exec(block.body)) {
2392
+ const prop = m[1].toLowerCase();
2393
+ const value = m[2].trim();
2394
+ if (value.includes("var(")) continue;
2395
+ if (COLOR_PROPS.has(prop) && COLOR_LITERAL_RE.test(value)) {
2396
+ const line = lineOfOffset(block, m.index);
2397
+ findings.push(
2398
+ mkFinding(
2399
+ ctx,
2400
+ "hardcoded-value",
2401
+ file,
2402
+ line,
2403
+ `Hard-coded colour \`${value}\` past the token layer`,
2404
+ sourceLine(source, line)
2405
+ )
2406
+ );
2407
+ continue;
2408
+ }
2409
+ if (SPACING_PROPS.has(prop)) {
2410
+ PX_RE.lastIndex = 0;
2411
+ let px;
2412
+ let flagged = false;
2413
+ while (px = PX_RE.exec(value)) {
2414
+ if (!EXCLUDED_PX.has(Math.abs(parseFloat(px[1])))) {
2415
+ flagged = true;
2416
+ break;
2417
+ }
2418
+ }
2419
+ if (flagged) {
2420
+ const line = lineOfOffset(block, m.index);
2421
+ findings.push(
2422
+ mkFinding(
2423
+ ctx,
2424
+ "hardcoded-value",
2425
+ file,
2426
+ line,
2427
+ `Hard-coded \`${value}\` past the token layer`,
2428
+ sourceLine(source, line)
2429
+ )
2430
+ );
2431
+ }
2432
+ }
2433
+ }
2434
+ }
2435
+ for (const attr of isMarkupHost(file) ? classAttributeValues(ctx.raw) : []) {
2436
+ for (const found of arbitraryValues(attr.classes)) {
2437
+ findings.push(
2438
+ mkFinding(
2439
+ ctx,
2440
+ "hardcoded-value",
2441
+ file,
2442
+ attr.line,
2443
+ found.kind === "colour" ? `Hard-coded colour \`${found.value}\` past the token layer (${found.utility}-[\u2026])` : `Hard-coded \`${found.value}\` past the token layer (${found.utility}-[\u2026])`,
2444
+ attr.classes.length > 80 ? `${attr.classes.slice(0, 77)}\u2026` : attr.classes
2445
+ )
2446
+ );
2447
+ }
2448
+ }
2449
+ return findings;
2450
+ }
2451
+ };
2452
+
2453
+ // src/check/detectors/violet-band-hue.ts
2454
+ var COLOR_TOKEN_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|hsl)a?\([^)]*\)/g;
2455
+ var HUE_MIN = 235;
2456
+ var HUE_MAX = 290;
2457
+ var MIN_SATURATION = 15;
2458
+ var MIN_LIGHTNESS = 8;
2459
+ var MAX_LIGHTNESS = 92;
2460
+ var violetBandHue = {
2461
+ name: "violet-band-hue",
2462
+ appliesTo: (file) => isStyleBearing(file),
2463
+ run(source, file, ctx) {
2464
+ const findings = [];
2465
+ const starts = buildLineIndex(source);
2466
+ COLOR_TOKEN_RE.lastIndex = 0;
2467
+ let m;
2468
+ while (m = COLOR_TOKEN_RE.exec(source)) {
2469
+ const c = extractColorComponents(m[0], ctx.tokens);
2470
+ if (!c) continue;
2471
+ const { h, s, l } = c.hsl;
2472
+ if (s < MIN_SATURATION || l < MIN_LIGHTNESS || l > MAX_LIGHTNESS) continue;
2473
+ if (h < HUE_MIN || h > HUE_MAX) continue;
2474
+ const line = lineForOffset(starts, m.index);
2475
+ findings.push(
2476
+ mkFinding(
2477
+ ctx,
2478
+ "violet-band-hue",
2479
+ file,
2480
+ line,
2481
+ `Violet/indigo hue (~${Math.round(h)}\xB0) \u2014 is this a deliberate brand choice, or the unspecified default?`,
2482
+ sourceLine(source, line)
2483
+ )
2484
+ );
2485
+ }
2486
+ return findings;
2487
+ }
2488
+ };
2489
+
2490
+ // src/check/registry.ts
2491
+ var DETECTORS = [
2492
+ gradientText,
2493
+ backdropBlur,
2494
+ pureBlackWhite,
2495
+ contrastFloor,
2496
+ focusRemoved,
2497
+ hardcodedValue,
2498
+ violetBandHue
2499
+ ];
2500
+ function getDetector(name) {
2501
+ return DETECTORS.find((d) => d.name === name);
2502
+ }
2503
+
2504
+ // src/check/run.ts
2505
+ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false) {
2506
+ const findings = [];
2507
+ const active = index.filter(
2508
+ (e) => !!e.detector && !!getDetector(e.detector)
2509
+ );
2510
+ const relevant = bucketFilter ? active.filter((e) => bucketFilter(e.bucket)) : active;
2511
+ if (relevant.length === 0) return findings;
2512
+ for (const file of files) {
2513
+ const applicable = relevant.map((entry) => ({ entry, detector: getDetector(entry.detector) })).filter(({ detector }) => detector.appliesTo(file));
2514
+ if (applicable.length === 0) continue;
2515
+ let raw;
2516
+ try {
2517
+ raw = readFileSync9(join14(projectRoot, file), "utf8");
2518
+ } catch {
2519
+ continue;
2520
+ }
2521
+ const source = maskComments(maskNonStyleRegions(raw, file));
2522
+ for (const { entry, detector } of applicable) {
2523
+ const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw };
2524
+ findings.push(...detector.run(source, file, ctx));
2525
+ }
2526
+ }
2527
+ return findings;
2528
+ }
2529
+
2530
+ // src/check/tokens.ts
2531
+ import { existsSync as existsSync8, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "fs";
2532
+ import { join as join15 } from "path";
2533
+ function loadTokenMap(projectRoot) {
2534
+ const tokensDir = join15(projectRoot, ".jig", "tokens");
2535
+ const tokens = {};
2536
+ if (!existsSync8(tokensDir)) return tokens;
2537
+ const files = readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort();
2538
+ for (const file of files) {
2539
+ const source = readFileSync10(join15(tokensDir, file), "utf8");
2540
+ for (const block of splitRuleBlocks(source)) {
2541
+ if (block.body.includes("{")) continue;
2542
+ if (block.selector.trim() !== ":root") continue;
2543
+ const re = /--([\w-]+)\s*:\s*([^;]+);/g;
2544
+ let m;
2545
+ while (m = re.exec(block.body)) {
2546
+ tokens[m[1]] = m[2].trim();
2547
+ }
2548
+ }
2549
+ }
2550
+ return tokens;
2551
+ }
2552
+
2553
+ // src/commands/check.ts
2554
+ var LEGACY_INDEX_REL = join16(".jig", "rules.index.json");
2555
+ function resolveIndexPath(projectRoot) {
2556
+ const legacy = join16(projectRoot, LEGACY_INDEX_REL);
2557
+ if (existsSync9(legacy)) return legacy;
2558
+ return join16(assetRoot(), "rules.index.json");
2559
+ }
2560
+ function resolveMode(projectRoot) {
2561
+ try {
2562
+ const config = JSON.parse(readFileSync11(join16(projectRoot, "jig.config.json"), "utf8"));
2563
+ const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
2564
+ if (modes.length === 1) return modes[0];
2565
+ return modes.length > 1 ? "mixed" : "unknown";
2566
+ } catch {
2567
+ return "unknown";
2568
+ }
2569
+ }
2570
+ var UNSUPPORTED_STYLE_HOSTS = [".elm", ".rs", ".templ", ".gohtml"];
2571
+ function summariseUnscanned(files) {
2572
+ const hit = files.filter((f) => !isStyleBearing(f) && hasExtension(f, UNSUPPORTED_STYLE_HOSTS));
2573
+ if (hit.length === 0) return void 0;
2574
+ const extensions = [...new Set(hit.map((f) => f.slice(f.lastIndexOf(".")).toLowerCase()))].sort();
2575
+ return { count: hit.length, extensions };
2576
+ }
2577
+ function countSpecs() {
2578
+ try {
2579
+ return loadSpecs(join16(assetRoot(), "rules")).length;
2580
+ } catch {
2581
+ return 0;
2582
+ }
2583
+ }
2584
+ function check(opts) {
2585
+ const indexPath = resolveIndexPath(opts.projectRoot);
2586
+ const index = validateIndex(JSON.parse(readFileSync11(indexPath, "utf8")));
2587
+ const tokens = loadTokenMap(opts.projectRoot);
2588
+ const selection = selectFiles(opts.projectRoot, opts.all);
2589
+ const { files } = selection;
2590
+ const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
2591
+ const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
2592
+ const projectParticipates = stylesheets.some((f) => {
2593
+ if (!hasExtension(f, CSS_EXTENSIONS)) return false;
2594
+ try {
2595
+ return participatesInTokenLayer(readFileSync11(join16(opts.projectRoot, f), "utf8"), tokens);
2596
+ } catch {
2597
+ return false;
2598
+ }
2599
+ });
2600
+ const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates);
2601
+ const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
2602
+ const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
2603
+ const noTokenLayer = (
2604
+ // `[].every()` is true, so without this guard a repo with no CSS at all is
2605
+ // told to add a token @import it has nowhere to put.
2606
+ cssFiles.length > 0 && cssFiles.every((f) => {
2607
+ try {
2608
+ return !participatesInTokenLayer(readFileSync11(join16(opts.projectRoot, f), "utf8"), tokens);
2609
+ } catch {
2610
+ return true;
2611
+ }
2612
+ })
2613
+ );
2614
+ const report = formatReport(findings, {
2615
+ totalRules: index.length,
2616
+ // From the CLI's own bundle, like `explain` — the spec count describes the
2617
+ // system, not whatever a project happens to have vendored. A legacy project
2618
+ // copy may have no `rules/` directory at all.
2619
+ totalSpecs: countSpecs(),
2620
+ version: opts.version,
2621
+ noTokenLayer,
2622
+ mode: resolveMode(opts.projectRoot),
2623
+ unscanned: summariseUnscanned(files)
2624
+ });
2625
+ return { findings, report, hasError };
2626
+ }
2627
+
2628
+ // src/commands/init.ts
2629
+ import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync15, writeFileSync as writeFileSync6 } from "fs";
2630
+ import { dirname as dirname7, join as join20 } from "path";
2631
+
2632
+ // src/init/detect.ts
2633
+ import { readFileSync as readFileSync12 } from "fs";
2634
+ import { join as join17 } from "path";
2635
+ var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
2636
+ var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
2637
+ var TAILWIND_V4_THEME_RE = /@theme\b/;
2638
+ var FRAMEWORK_DEPS = [
2639
+ ["next", "Next.js"],
2640
+ ["nuxt", "Nuxt"],
2641
+ ["@remix-run/react", "Remix"],
2642
+ ["astro", "Astro"],
2643
+ ["svelte", "Svelte"],
2644
+ ["vue", "Vue"],
2645
+ ["react", "React"],
2646
+ ["solid-js", "Solid"]
2647
+ ];
2648
+ function detectFramework(projectRoot) {
2649
+ let pkg;
2650
+ try {
2651
+ pkg = JSON.parse(readFileSync12(join17(projectRoot, "package.json"), "utf8"));
2652
+ } catch {
2653
+ return void 0;
2654
+ }
2655
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
2656
+ for (const [dep, label] of FRAMEWORK_DEPS) {
2657
+ if (deps[dep]) return label;
2658
+ }
2659
+ return void 0;
2660
+ }
2661
+ function detect(projectRoot) {
2662
+ const allFiles = wholeRepoFiles(projectRoot);
2663
+ const cssFiles = allFiles.filter((f) => hasExtension(f, CSS_EXTENSIONS));
2664
+ const tailwindConfigFile = allFiles.find((f) => TAILWIND_CONFIG_RE.test(f.split("/").pop() ?? ""));
2665
+ let tailwindV4EntryFile;
2666
+ for (const f of cssFiles) {
2667
+ let source;
2668
+ try {
2669
+ source = readFileSync12(join17(projectRoot, f), "utf8");
2670
+ } catch {
2671
+ continue;
2672
+ }
2673
+ if (TAILWIND_V4_IMPORT_RE.test(source) || TAILWIND_V4_THEME_RE.test(source)) {
2674
+ tailwindV4EntryFile = f;
2675
+ break;
2676
+ }
2677
+ }
2678
+ let cssSystem;
2679
+ if (tailwindV4EntryFile) cssSystem = "tailwind-v4";
2680
+ else if (tailwindConfigFile) cssSystem = "tailwind-v3";
2681
+ else if (cssFiles.length > 0) cssSystem = "plain-css";
2682
+ else cssSystem = "unknown";
2683
+ return { cssSystem, cssFiles, tailwindConfigFile, tailwindV4EntryFile, framework: detectFramework(projectRoot) };
2684
+ }
2685
+
2686
+ // src/init/gitignore.ts
2687
+ import { execFileSync as execFileSync2 } from "child_process";
2688
+ function isIgnored(projectRoot, relPath) {
2689
+ const candidates = relPath.endsWith("/") ? [relPath] : [relPath, `${relPath}/`];
2690
+ return candidates.some((path) => {
2691
+ try {
2692
+ execFileSync2("git", ["check-ignore", "-q", "--", path], {
2693
+ cwd: projectRoot,
2694
+ stdio: ["ignore", "ignore", "ignore"]
2695
+ });
2696
+ return true;
2697
+ } catch {
2698
+ return false;
2699
+ }
2700
+ });
2701
+ }
2702
+
2703
+ // src/init/derive.ts
2704
+ import { readFileSync as readFileSync13 } from "fs";
2705
+ import { join as join18 } from "path";
2706
+ var DEFAULT_PROPOSAL = {
2707
+ h: 264,
2708
+ s: 0,
2709
+ l: 15,
2710
+ source: "default",
2711
+ detail: "no colour found in the project; using the unbranded default (near-black, matches tokens/brand.default.css)"
2712
+ };
2713
+ var NAME_PRIORITY_RE = /brand|primary|accent/i;
2714
+ var EXACT_NAMES = /* @__PURE__ */ new Set(["brand", "primary", "accent"]);
2715
+ var MIN_CHROMATIC_SATURATION = 15;
2716
+ function round(n) {
2717
+ return Math.round(n * 10) / 10;
2718
+ }
2719
+ function toProposal(c, source, why) {
2720
+ return { h: round(c.hsl.h), s: round(c.hsl.s), l: round(c.hsl.l), source, detail: why };
2721
+ }
2722
+ function mostFrequent(candidates) {
2723
+ if (candidates.length === 0) return void 0;
2724
+ const counts = /* @__PURE__ */ new Map();
2725
+ for (const c of candidates) {
2726
+ const key = `${Math.round(c.hsl.h)}|${Math.round(c.hsl.s)}|${Math.round(c.hsl.l)}`;
2727
+ const existing = counts.get(key);
2728
+ if (existing) existing.count += 1;
2729
+ else counts.set(key, { count: 1, candidate: c });
2730
+ }
2731
+ return [...counts.values()].sort((a, b) => b.count - a.count || b.candidate.hsl.s - a.candidate.hsl.s)[0].candidate;
2732
+ }
2733
+ function fromCssCustomProperties(projectRoot, cssFiles) {
2734
+ const candidates = [];
2735
+ for (const file of cssFiles) {
2736
+ let source;
2737
+ try {
2738
+ source = readFileSync13(join18(projectRoot, file), "utf8");
2739
+ } catch {
2740
+ continue;
2741
+ }
2742
+ for (const block of splitRuleBlocks(source)) {
2743
+ if (block.body.includes("{")) continue;
2744
+ const re = /--([\w-]+)\s*:\s*([^;]+);/g;
2745
+ let m;
2746
+ while (m = re.exec(block.body)) {
2747
+ const [, name, rawValue] = m;
2748
+ const value = rawValue.trim();
2749
+ const comps = extractColorComponents(value, {});
2750
+ if (!comps || comps.alpha < 0.999) continue;
2751
+ candidates.push({ value, hsl: comps.hsl, name, file });
2752
+ }
2753
+ }
2754
+ }
2755
+ if (candidates.length === 0) return null;
2756
+ const namedChromatic = candidates.filter(
2757
+ (c) => c.name && NAME_PRIORITY_RE.test(c.name) && c.hsl.s >= MIN_CHROMATIC_SATURATION
2758
+ );
2759
+ if (namedChromatic.length > 0) {
2760
+ const exact = namedChromatic.find((c) => EXACT_NAMES.has(c.name.toLowerCase()));
2761
+ const named = exact ?? namedChromatic[0];
2762
+ return toProposal(named, "css-custom-property", `--${named.name}: ${named.value} in ${named.file}`);
2763
+ }
2764
+ const chromatic = candidates.filter((c) => c.hsl.s >= MIN_CHROMATIC_SATURATION);
2765
+ const pool = chromatic.length > 0 ? chromatic : candidates;
2766
+ const chosen = mostFrequent(pool) ?? pool[0];
2767
+ return toProposal(
2768
+ chosen,
2769
+ "css-custom-property",
2770
+ `--${chosen.name}: ${chosen.value} in ${chosen.file} (most repeated custom-property colour)`
2771
+ );
2772
+ }
2773
+ function balancedBraceBody(source, openIdx) {
2774
+ let depth = 0;
2775
+ for (let i = openIdx; i < source.length; i++) {
2776
+ if (source[i] === "{") depth++;
2777
+ else if (source[i] === "}") {
2778
+ depth--;
2779
+ if (depth === 0) return source.slice(openIdx + 1, i);
2780
+ }
2781
+ }
2782
+ return null;
2783
+ }
2784
+ function fromTailwindConfig(projectRoot, tailwindConfigFile) {
2785
+ let source;
2786
+ try {
2787
+ source = readFileSync13(join18(projectRoot, tailwindConfigFile), "utf8");
2788
+ } catch {
2789
+ return null;
2790
+ }
2791
+ const colorsBlock = (() => {
2792
+ const extendIdx = source.search(/\bextend\s*:\s*\{/);
2793
+ if (extendIdx !== -1) {
2794
+ const extendBraceIdx = source.indexOf("{", extendIdx);
2795
+ const extendBody = balancedBraceBody(source, extendBraceIdx);
2796
+ if (extendBody) {
2797
+ const colorsIdx2 = extendBody.search(/\bcolors\s*:\s*\{/);
2798
+ if (colorsIdx2 !== -1) {
2799
+ const braceIdx = extendBody.indexOf("{", colorsIdx2);
2800
+ const body = balancedBraceBody(extendBody, braceIdx);
2801
+ if (body) return body;
2802
+ }
2803
+ }
2804
+ }
2805
+ const colorsIdx = source.search(/\bcolors\s*:\s*\{/);
2806
+ if (colorsIdx !== -1) {
2807
+ const braceIdx = source.indexOf("{", colorsIdx);
2808
+ return balancedBraceBody(source, braceIdx);
2809
+ }
2810
+ return null;
2811
+ })();
2812
+ if (!colorsBlock) return null;
2813
+ const candidates = [];
2814
+ const re = /['"]?([\w-]+)['"]?\s*:\s*['"]([^'"]+)['"]/g;
2815
+ let m;
2816
+ while (m = re.exec(colorsBlock)) {
2817
+ const [, name, value] = m;
2818
+ const comps = extractColorComponents(value, {});
2819
+ if (!comps || comps.alpha < 0.999) continue;
2820
+ candidates.push({ value, hsl: comps.hsl, name, file: tailwindConfigFile });
2821
+ }
2822
+ if (candidates.length === 0) return null;
2823
+ const namedChromatic = candidates.filter(
2824
+ (c) => NAME_PRIORITY_RE.test(c.name) && c.hsl.s >= MIN_CHROMATIC_SATURATION
2825
+ );
2826
+ const named = namedChromatic.length > 0 ? namedChromatic.find((c) => EXACT_NAMES.has(c.name.toLowerCase())) ?? namedChromatic[0] : void 0;
2827
+ const chromatic = candidates.filter((c) => c.hsl.s >= MIN_CHROMATIC_SATURATION);
2828
+ const pool = chromatic.length > 0 ? chromatic : candidates;
2829
+ const chosen = named ?? mostFrequent(pool) ?? pool[0];
2830
+ return toProposal(
2831
+ chosen,
2832
+ "tailwind-config",
2833
+ `theme.extend.colors.${chosen.name}: '${chosen.value}' in ${tailwindConfigFile}`
2834
+ );
2835
+ }
2836
+ var HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
2837
+ var RGB_HSL_RE = /(?:rgb|hsl)a?\([^)]*\)/g;
2838
+ function fromLiteralFrequency(projectRoot, cssFiles) {
2839
+ const candidates = [];
2840
+ for (const file of cssFiles) {
2841
+ let source;
2842
+ try {
2843
+ source = readFileSync13(join18(projectRoot, file), "utf8");
2844
+ } catch {
2845
+ continue;
2846
+ }
2847
+ for (const re of [HEX_RE, RGB_HSL_RE]) {
2848
+ re.lastIndex = 0;
2849
+ let m;
2850
+ while (m = re.exec(source)) {
2851
+ const comps = extractColorComponents(m[0], {});
2852
+ if (!comps || comps.alpha < 0.999) continue;
2853
+ if (comps.hsl.s < MIN_CHROMATIC_SATURATION) continue;
2854
+ candidates.push({ value: m[0], hsl: comps.hsl, file });
2855
+ }
2856
+ }
2857
+ }
2858
+ if (candidates.length === 0) return null;
2859
+ const chosen = mostFrequent(candidates) ?? candidates[0];
2860
+ return toProposal(
2861
+ chosen,
2862
+ "literal-frequency",
2863
+ `${chosen.value} \u2014 the most frequent literal chromatic colour across ${cssFiles.length} stylesheet(s), in ${chosen.file}`
2864
+ );
2865
+ }
2866
+ function deriveBrandColor(projectRoot, cssFiles, tailwindConfigFile) {
2867
+ return fromCssCustomProperties(projectRoot, cssFiles) ?? (tailwindConfigFile ? fromTailwindConfig(projectRoot, tailwindConfigFile) : null) ?? fromLiteralFrequency(projectRoot, cssFiles) ?? DEFAULT_PROPOSAL;
2868
+ }
2869
+
2870
+ // src/init/validate.ts
2871
+ var BG_RAISED = { r: 255, g: 255, b: 255 };
2872
+ var BG_BASE_APPROX = { r: 250, g: 250, b: 248 };
2873
+ function compositeOver(fg, alpha, bg) {
2874
+ return {
2875
+ r: Math.round(fg.r * alpha + bg.r * (1 - alpha)),
2876
+ g: Math.round(fg.g * alpha + bg.g * (1 - alpha)),
2877
+ b: Math.round(fg.b * alpha + bg.b * (1 - alpha))
2878
+ };
2879
+ }
2880
+ var FILL_COMPOSITED = compositeOver({ r: 0, g: 0, b: 0 }, 0.04, BG_BASE_APPROX);
2881
+ var CONTRAST_FLOOR = 4.5;
2882
+ function toRgb(h, s, l) {
2883
+ const comps = extractColorComponents(`hsl(${h} ${s}% ${l}%)`, {});
2884
+ if (!comps) throw new Error(`validate: could not resolve hsl(${h} ${s}% ${l}%) \u2014 this should never happen`);
2885
+ return comps.rgb;
2886
+ }
2887
+ function worstRatio(h, s, l) {
2888
+ const rgb = toRgb(h, s, l);
2889
+ const vsRaised = contrastRatio(rgb, BG_RAISED);
2890
+ const vsFill = contrastRatio(rgb, FILL_COMPOSITED);
2891
+ return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
2892
+ }
2893
+ var DARK_BRAND_L = 88;
2894
+ var DARK_SURFACE_S = 6;
2895
+ var DARK_RAISED_L = 15;
2896
+ var DARK_BASE_L = 10;
2897
+ var DARK_FILL_ALPHA = 0.06;
2898
+ function darkWorstRatio(h, s) {
2899
+ const brand = toRgb(h, s, DARK_BRAND_L);
2900
+ const raised = toRgb(h, DARK_SURFACE_S, DARK_RAISED_L);
2901
+ const base = toRgb(h, DARK_SURFACE_S, DARK_BASE_L);
2902
+ const fill = compositeOver({ r: 255, g: 255, b: 255 }, DARK_FILL_ALPHA, base);
2903
+ const vsRaised = contrastRatio(brand, raised);
2904
+ const vsFill = contrastRatio(brand, fill);
2905
+ return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
2906
+ }
2907
+ function nearestPassingLightness(h, s, l) {
2908
+ if (worstRatio(h, s, l).worst >= CONTRAST_FLOOR) return l;
2909
+ for (let delta = 1; delta <= 100; delta++) {
2910
+ for (const candidate of [l - delta, l + delta]) {
2911
+ if (candidate < 0 || candidate > 100) continue;
2912
+ if (worstRatio(h, s, candidate).worst >= CONTRAST_FLOOR) return candidate;
2913
+ }
2914
+ }
2915
+ return null;
2916
+ }
2917
+ var VIOLET_HUE_MIN = 235;
2918
+ var VIOLET_HUE_MAX = 290;
2919
+ var HUE_MIN_SATURATION = 15;
2920
+ var HUE_MIN_LIGHTNESS = 8;
2921
+ var HUE_MAX_LIGHTNESS = 92;
2922
+ function isHueVisible(s, l) {
2923
+ return s >= HUE_MIN_SATURATION && l >= HUE_MIN_LIGHTNESS && l <= HUE_MAX_LIGHTNESS;
2924
+ }
2925
+ function isInVioletBand(h, s, l) {
2926
+ return isHueVisible(s, l) && h >= VIOLET_HUE_MIN && h <= VIOLET_HUE_MAX;
2927
+ }
2928
+ function systemCollision(h, s, l) {
2929
+ if (!isHueVisible(s, l)) return null;
2930
+ if (h <= 20 || h >= 340) return "red";
2931
+ if (h >= 25 && h <= 65) return "amber";
2932
+ if (h >= 100 && h <= 170) return "green";
2933
+ return null;
2934
+ }
2935
+ function validateBrandColor(h, s, l) {
2936
+ const light = worstRatio(h, s, l);
2937
+ const dark = darkWorstRatio(h, s);
2938
+ const worst = Math.min(light.worst, dark.worst);
2939
+ const passesContrast = worst >= CONTRAST_FLOOR;
2940
+ return {
2941
+ ratioVsRaised: light.vsRaised,
2942
+ ratioVsFill: light.vsFill,
2943
+ lightRatioVsRaised: light.vsRaised,
2944
+ lightRatioVsFill: light.vsFill,
2945
+ lightWorstRatio: light.worst,
2946
+ darkRatioVsRaised: dark.vsRaised,
2947
+ darkRatioVsFill: dark.vsFill,
2948
+ darkWorstRatio: dark.worst,
2949
+ worstRatio: worst,
2950
+ passesContrast,
2951
+ nearestPassingLightness: passesContrast ? void 0 : nearestPassingLightness(h, s, l) ?? void 0,
2952
+ violetBand: isInVioletBand(h, s, l),
2953
+ systemCollision: systemCollision(h, s, l)
2954
+ };
2955
+ }
2956
+
2957
+ // src/init/brand-file.ts
2958
+ var TOP_COMMENT_FIRST_LINE = "brand.default.css \u2014 unbranded default.";
2959
+ var TOP_COMMENT_LAST_LINE = "Copy to brand.<client>.css and override. Never edit this file per project.";
2960
+ function round2(n) {
2961
+ return Math.round(n);
2962
+ }
2963
+ function renderBrandFile(defaultCssRaw, projectSlug, version2, proposal) {
2964
+ const fileName = `brand.${projectSlug}.css`;
2965
+ const header = vendorHeader(fileName, version2, "css", null);
2966
+ if (!defaultCssRaw.includes(TOP_COMMENT_FIRST_LINE) || !defaultCssRaw.includes(TOP_COMMENT_LAST_LINE)) {
2967
+ throw new Error(
2968
+ `brand-file: tokens/brand.default.css does not match the expected shape \u2014 ${TOP_COMMENT_FIRST_LINE} / ${TOP_COMMENT_LAST_LINE} not found.`
2969
+ );
2970
+ }
2971
+ let body = defaultCssRaw.replace(
2972
+ TOP_COMMENT_FIRST_LINE,
2973
+ `${fileName} \u2014 generated by \`jig init\`.`
2974
+ );
2975
+ body = body.replace(
2976
+ TOP_COMMENT_LAST_LINE,
2977
+ `Brand colour derived from: ${proposal.detail} (${proposal.source}). Edit --brand-h/-s/-l below directly \u2014 \`jig update\` will not touch this file.`
2978
+ );
2979
+ const substitute = (source, pattern, replacement, label) => {
2980
+ if (!pattern.test(source)) {
2981
+ throw new Error(`brand-file: tokens/brand.default.css does not match the expected shape \u2014 ${label} not found.`);
2982
+ }
2983
+ return source.replace(pattern, replacement);
2984
+ };
2985
+ body = substitute(body, /--brand-h:\s*[\d.]+;/, `--brand-h: ${round2(proposal.h)};`, "--brand-h");
2986
+ body = substitute(body, /--brand-s:\s*[\d.]+%;/, `--brand-s: ${round2(proposal.s)}%;`, "--brand-s");
2987
+ body = substitute(body, /--brand-l:\s*[\d.]+%;/, `--brand-l: ${round2(proposal.l)}%;`, "--brand-l");
2988
+ return header + body;
2989
+ }
2990
+ function brandFileName(projectSlug) {
2991
+ return `brand.${projectSlug}.css`;
2992
+ }
2993
+
2994
+ // src/init/import-path.ts
2995
+ import { relative as relative2, sep as sep2 } from "path";
2996
+ function relativeImportPath(fromAbsDir, toAbsFile) {
2997
+ let rel = relative2(fromAbsDir, toAbsFile).split(sep2).join("/");
2998
+ if (!rel.startsWith(".")) rel = `./${rel}`;
2999
+ return rel;
3000
+ }
3001
+
3002
+ // src/init/project-name.ts
3003
+ import { readFileSync as readFileSync14 } from "fs";
3004
+ import { basename, join as join19 } from "path";
3005
+ function slugify(name) {
3006
+ const withoutScope = name.replace(/^@[^/]+\//, "");
3007
+ const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3008
+ return slug || "project";
3009
+ }
3010
+ function deriveProjectSlug(projectRoot) {
3011
+ try {
3012
+ const pkg = JSON.parse(readFileSync14(join19(projectRoot, "package.json"), "utf8"));
3013
+ if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
3014
+ } catch {
3015
+ }
3016
+ return slugify(basename(projectRoot));
3017
+ }
3018
+
3019
+ // src/commands/init.ts
3020
+ var MODES = ["editorial", "product", "operator"];
3021
+ var DEFAULT_SURFACES = [{ match: "/", mode: "product" }];
3022
+ async function defaultPrompt(question) {
3023
+ const { createInterface } = await import("readline/promises");
3024
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3025
+ try {
3026
+ return (await rl.question(question)).trim();
3027
+ } finally {
3028
+ rl.close();
3029
+ }
3030
+ }
3031
+ function validateRounded(p) {
3032
+ return validateBrandColor(Math.round(p.h), Math.round(p.s), Math.round(p.l));
3033
+ }
3034
+ function describeValidation(v) {
3035
+ const lines = [];
3036
+ const modes = `light ${v.lightWorstRatio.toFixed(2)}:1, dark ${v.darkWorstRatio.toFixed(2)}:1`;
3037
+ if (v.passesContrast) {
3038
+ lines.push(
3039
+ ` contrast OK \u2014 ${modes} (floor 4.5:1; light is ${v.ratioVsRaised.toFixed(2)} vs --color-bg-raised and ${v.ratioVsFill.toFixed(2)} vs --color-fill)`
3040
+ );
3041
+ } else {
3042
+ const failing = v.lightWorstRatio < v.darkWorstRatio ? "light" : "dark";
3043
+ lines.push(
3044
+ ` contrast FAILS in ${failing} mode \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${modes})`
3045
+ );
3046
+ if (v.nearestPassingLightness !== void 0) {
3047
+ lines.push(` nearest passing lightness at the same hue/saturation: ${v.nearestPassingLightness}%`);
3048
+ }
3049
+ }
3050
+ if (v.violetBand) {
3051
+ lines.push(
3052
+ " A-01: this hue falls in the violet/indigo band \u2014 the unspecified-default hue. Confirm this was a deliberate choice."
3053
+ );
3054
+ }
3055
+ if (v.systemCollision) {
3056
+ lines.push(
3057
+ ` E-64: this hue reads as ${v.systemCollision} \u2014 a system colour. Do not use it for interactive elements (links, buttons); keep it decorative.`
3058
+ );
3059
+ }
3060
+ return lines;
3061
+ }
3062
+ function parseSurfaces(answer, fallback) {
3063
+ const trimmed = answer.trim();
3064
+ if (!trimmed) return fallback;
3065
+ const parts = trimmed.split(",").map((p) => p.trim()).filter(Boolean);
3066
+ const parsed = [];
3067
+ for (const part of parts) {
3068
+ const idx = part.lastIndexOf(":");
3069
+ if (idx === -1) return fallback;
3070
+ const match = part.slice(0, idx).trim();
3071
+ const mode = part.slice(idx + 1).trim();
3072
+ if (!match || !MODES.includes(mode)) return fallback;
3073
+ parsed.push({ match, mode });
3074
+ }
3075
+ return parsed.length > 0 ? parsed : fallback;
3076
+ }
3077
+ function fileState(projectRoot, absPath, relPath, initManifest) {
3078
+ const existsOnDisk = existsSync10(absPath);
3079
+ const tracked = !!initManifest && relPath in initManifest.files;
3080
+ const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
3081
+ return { existsOnDisk, tracked, modified };
3082
+ }
3083
+ function isValidSurfaceArray(v) {
3084
+ return Array.isArray(v) && v.length > 0 && v.every(
3085
+ (s) => typeof s === "object" && s !== null && typeof s.match === "string" && MODES.includes(s.mode)
3086
+ );
3087
+ }
3088
+ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
3089
+ if (!existsSync10(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3090
+ try {
3091
+ const parsed = JSON.parse(readFileSync15(configAbsPath, "utf8"));
3092
+ const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
3093
+ const brandResolves = brandCandidate ? existsSync10(join20(projectRoot, ...brandCandidate.split("/"))) : false;
3094
+ return {
3095
+ brand: brandResolves ? brandCandidate : fallbackBrand,
3096
+ surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
3097
+ };
3098
+ } catch {
3099
+ return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3100
+ }
3101
+ }
3102
+ function isCssModule(path) {
3103
+ return /\.module\.(css|scss)$/i.test(path);
3104
+ }
3105
+ function findWireTarget(d) {
3106
+ if (d.cssSystem === "tailwind-v4" && d.tailwindV4EntryFile) return d.tailwindV4EntryFile;
3107
+ const candidates = d.cssFiles.filter((f) => !isCssModule(f));
3108
+ if (candidates.length === 1) return candidates[0];
3109
+ return null;
3110
+ }
3111
+ var MODE_IMPORT_RE = new RegExp(`@import\\s+["']([^"']*/)?mode\\.(${MODES.join("|")})\\.css["'];?\\r?\\n?`);
3112
+ var CHARSET_RE = /^@charset\s+["'][^"']*["'];\r?\n?/;
3113
+ function insertAfterCharset(content, insertion) {
3114
+ const m = CHARSET_RE.exec(content);
3115
+ const charsetLen = m ? m[0].length : 0;
3116
+ return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
3117
+ }
3118
+ function wireImport(absPath, brandImport, modeImport, mode) {
3119
+ const content = existsSync10(absPath) ? readFileSync15(absPath, "utf8") : "";
3120
+ const brandImportLine = `@import "${brandImport}";`;
3121
+ const modeImportLine = `@import "${modeImport}";`;
3122
+ const brandPresent = content.includes(brandImportLine);
3123
+ const existingModeMatch = MODE_IMPORT_RE.exec(content);
3124
+ if (brandPresent) {
3125
+ if (existingModeMatch && existingModeMatch[0].trim() === modeImportLine) {
3126
+ return { status: "already-present" };
3127
+ }
3128
+ if (existingModeMatch) {
3129
+ const previousMode = existingModeMatch[2];
3130
+ const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
3131
+ ` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
3132
+ writeFileSync6(absPath, next3, "utf8");
3133
+ return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
3134
+ }
3135
+ const brandIdx = content.indexOf(brandImportLine);
3136
+ let insertAt = brandIdx + brandImportLine.length;
3137
+ if (content[insertAt] === "\r") insertAt++;
3138
+ if (content[insertAt] === "\n") insertAt++;
3139
+ const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
3140
+ writeFileSync6(absPath, next2, "utf8");
3141
+ return { status: "rewired", detail: `added missing mode import: ${mode}` };
3142
+ }
3143
+ const lines = `${brandImportLine}
3144
+ ${modeImportLine}
3145
+ `;
3146
+ const tailwindImportMatch = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
3147
+ let next;
3148
+ if (tailwindImportMatch) {
3149
+ const idx = tailwindImportMatch.index + tailwindImportMatch[0].length;
3150
+ next = content.slice(0, idx) + lines + content.slice(idx);
3151
+ } else {
3152
+ next = insertAfterCharset(content, content ? `${lines}
3153
+ ` : lines);
3154
+ }
3155
+ mkdirSync6(dirname7(absPath), { recursive: true });
3156
+ writeFileSync6(absPath, next, "utf8");
3157
+ return { status: "wired" };
3158
+ }
3159
+ async function init(opts) {
3160
+ const log = opts.log ?? ((line) => console.log(line));
3161
+ const prompt = opts.prompt ?? defaultPrompt;
3162
+ const migrateLegacy = async (report, describe) => {
3163
+ if (!report.present) return;
3164
+ for (const line of describe(report)) log(line);
3165
+ const removable = removableLegacyFiles(report);
3166
+ if (removable.length === 0) return;
3167
+ if (opts.yes) {
3168
+ log(" Re-run without --yes to be prompted for removal, or delete them yourself.");
3169
+ return;
3170
+ }
3171
+ const answer = (await prompt(` Remove these ${removable.length} unedited legacy file(s)? [y/N]: `)).toLowerCase();
3172
+ if (answer === "y" || answer === "yes") {
3173
+ removeLegacyFiles(opts.projectRoot, removable);
3174
+ log(` Removed ${removable.length} file(s).`);
3175
+ } else {
3176
+ log(" Left them in place.");
3177
+ }
3178
+ };
3179
+ await migrateLegacy(detectLegacyRules(opts.projectRoot), describeLegacyReport);
3180
+ await migrateLegacy(detectLegacyCursorRules(opts.projectRoot), describeLegacyCursorReport);
3181
+ const detection = detect(opts.projectRoot);
3182
+ log(`Detected: ${detection.cssSystem}${detection.framework ? ` (${detection.framework})` : ""}`);
3183
+ if (detection.cssFiles.length === 0) {
3184
+ log(" No stylesheets found.");
3185
+ } else {
3186
+ log(` ${detection.cssFiles.length} stylesheet(s) found.`);
3187
+ }
3188
+ let proposal = deriveBrandColor(opts.projectRoot, detection.cssFiles, detection.tailwindConfigFile);
3189
+ let validation = validateRounded(proposal);
3190
+ log(`Proposed brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%) \u2014 ${proposal.detail}`);
3191
+ for (const line of describeValidation(validation)) log(line);
3192
+ let surfaces = DEFAULT_SURFACES;
3193
+ if (!opts.yes) {
3194
+ for (let attempt = 0; attempt < 3; attempt++) {
3195
+ const answer = await prompt(
3196
+ `Use this brand colour? [Y/n, or paste a hex to override]: `
3197
+ );
3198
+ const normalized = answer.toLowerCase();
3199
+ if (normalized === "" || normalized === "y" || normalized === "yes") break;
3200
+ if (normalized === "n" || normalized === "no") break;
3201
+ const comps = extractColorComponents(answer, {});
3202
+ if (!comps) {
3203
+ log(` Could not parse '${answer}' as a colour \u2014 keeping the proposal.`);
3204
+ break;
3205
+ }
3206
+ proposal = { h: comps.hsl.h, s: comps.hsl.s, l: comps.hsl.l, source: "default", detail: `user-provided: ${answer}` };
3207
+ validation = validateRounded(proposal);
3208
+ log(`Brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%)`);
3209
+ for (const line of describeValidation(validation)) log(line);
3210
+ }
3211
+ const surfaceAnswer = await prompt(
3212
+ `Surface \u2192 mode mapping. Enter to accept '/' \u2192 product, or 'match:mode,match:mode' (modes: ${MODES.join("/")}): `
3213
+ );
3214
+ surfaces = parseSurfaces(surfaceAnswer, DEFAULT_SURFACES);
3215
+ if (surfaceAnswer.trim() && surfaces === DEFAULT_SURFACES) {
3216
+ log(
3217
+ ` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
3218
+ );
3219
+ }
3220
+ }
3221
+ if (opts.yes) {
3222
+ log(
3223
+ `Surface \u2192 mode: ${surfaces.map((s) => `'${s.match}' \u2192 ${s.mode}`).join(", ")} \u2014 the default, not inferred from this project. Mode sets density, type scale and control sizes, and it wins over an agent's own inference, so change it in jig.config.json if another mode fits (${MODES.join("/")}).`
3224
+ );
3225
+ }
3226
+ if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
3227
+ const originalL = proposal.l;
3228
+ proposal = { ...proposal, l: validation.nearestPassingLightness };
3229
+ validation = validateRounded(proposal);
3230
+ log(
3231
+ `
3232
+ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192 l:${proposal.l}% (hue/saturation unchanged).`
3233
+ );
3234
+ }
3235
+ const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
3236
+ const projectSlug = deriveProjectSlug(opts.projectRoot);
3237
+ const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
3238
+ const brandAbsPath = join20(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
3239
+ const configRelPath = "jig.config.json";
3240
+ const configAbsPath = join20(opts.projectRoot, configRelPath);
3241
+ const initManifest = readInitManifest(opts.projectRoot);
3242
+ const files = { ...initManifest?.files ?? {} };
3243
+ const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
3244
+ const configState = fileState(opts.projectRoot, configAbsPath, configRelPath, initManifest);
3245
+ let brandAction = "written";
3246
+ if (brandState.existsOnDisk && !brandState.tracked) brandAction = "skipped-untracked";
3247
+ else if (brandState.modified) brandAction = "skipped-edited";
3248
+ let configAction = "written";
3249
+ if (configState.existsOnDisk && !configState.tracked) configAction = "skipped-untracked";
3250
+ else if (configState.modified) configAction = "skipped-edited";
3251
+ if (!opts.yes && (brandAction !== "written" || configAction !== "written")) {
3252
+ if (brandAction !== "written") {
3253
+ log(`
3254
+ ${brandRelPath} already exists and was not created by 'jig init' (or has been edited).`);
3255
+ const answer = (await prompt(` [k]eep it, or [r]efresh it with the freshly derived colour? [k/r]: `)).toLowerCase();
3256
+ brandAction = answer.startsWith("r") ? "written" : "kept";
3257
+ }
3258
+ if (configAction !== "written") {
3259
+ log(`
3260
+ ${configRelPath} already exists and was not created by 'jig init' (or has been edited).`);
3261
+ const answer = (await prompt(` [k]eep it, [r]efresh it, or [m]erge (keep your surfaces, only fix a missing brand path)? [k/r/m]: `)).toLowerCase();
3262
+ configAction = answer.startsWith("r") ? "written" : answer.startsWith("m") ? "merged" : "kept";
3263
+ }
3264
+ } else {
3265
+ if (brandAction !== "written") log(` ${brandRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
3266
+ if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
3267
+ }
3268
+ if (brandAction === "written") {
3269
+ const defaultCssRaw = readFileSync15(join20(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
3270
+ const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
3271
+ mkdirSync6(dirname7(brandAbsPath), { recursive: true });
3272
+ writeFileSync6(brandAbsPath, content, "utf8");
3273
+ files[brandRelPath] = checksum(content);
3274
+ }
3275
+ if (configAction === "written") {
3276
+ const config = { brand: brandRelPath, surfaces };
3277
+ const content = `${JSON.stringify(config, null, 2)}
3278
+ `;
3279
+ writeFileSync6(configAbsPath, content, "utf8");
3280
+ files[configRelPath] = checksum(content);
3281
+ } else if (configAction === "merged") {
3282
+ let existing = {};
3283
+ try {
3284
+ existing = JSON.parse(readFileSync15(configAbsPath, "utf8"));
3285
+ } catch {
3286
+ existing = {};
3287
+ }
3288
+ const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
3289
+ const brandStillResolves = existingBrand ? existsSync10(join20(opts.projectRoot, ...existingBrand.split("/"))) : false;
3290
+ const merged = {
3291
+ brand: brandStillResolves ? existingBrand : brandRelPath,
3292
+ surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
3293
+ };
3294
+ const content = `${JSON.stringify(merged, null, 2)}
3295
+ `;
3296
+ writeFileSync6(configAbsPath, content, "utf8");
3297
+ files[configRelPath] = checksum(content);
3298
+ }
3299
+ const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
3300
+ const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
3301
+ const wiringBrandAbsPath = join20(opts.projectRoot, ...effectiveConfig.brand.split("/"));
3302
+ const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
3303
+ const modeAbsPaths = {};
3304
+ for (const mode of declaredModes) {
3305
+ const modeFileName = `mode.${mode}.css`;
3306
+ const modeAbsPath2 = join20(opts.projectRoot, ".jig", "tokens", modeFileName);
3307
+ modeAbsPaths[mode] = modeAbsPath2;
3308
+ const modeRelPath = relKey(".jig", "tokens", modeFileName);
3309
+ const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
3310
+ let modeAction = "written";
3311
+ if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
3312
+ else if (modeState.modified) modeAction = "skipped-edited";
3313
+ if (modeAction === "written") {
3314
+ const modeSourceRaw = readFileSync15(join20(opts.packageRoot, "tokens", modeFileName), "utf8");
3315
+ const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
3316
+ mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
3317
+ writeFileSync6(modeAbsPath2, content, "utf8");
3318
+ files[modeRelPath] = checksum(content);
3319
+ } else {
3320
+ log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
3321
+ }
3322
+ }
3323
+ const modeAbsPath = modeAbsPaths[primaryMode];
3324
+ writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
3325
+ const wireTarget = findWireTarget(detection);
3326
+ let wiring;
3327
+ if (wireTarget) {
3328
+ const targetAbsDir = dirname7(join20(opts.projectRoot, wireTarget));
3329
+ const brandImport = relativeImportPath(targetAbsDir, wiringBrandAbsPath);
3330
+ const modeImport = relativeImportPath(targetAbsDir, modeAbsPath);
3331
+ const snippet = `@import "${brandImport}";
3332
+ @import "${modeImport}";`;
3333
+ try {
3334
+ const outcome = wireImport(join20(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
3335
+ wiring = { target: wireTarget, status: outcome.status, snippet };
3336
+ const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
3337
+ log(`
3338
+ ${verb} ${wireTarget}:`);
3339
+ if (outcome.detail) log(` ${outcome.detail}`);
3340
+ log(` ${snippet.split("\n").join("\n ")}`);
3341
+ } catch (err) {
3342
+ wiring = { target: null, status: "print-only", snippet };
3343
+ log(`
3344
+ Could not write to ${wireTarget}: ${err.message}`);
3345
+ log("Add this near the top of your global stylesheet instead (adjust the path to wherever you paste it):");
3346
+ log(` ${snippet.split("\n").join("\n ")}`);
3347
+ }
3348
+ } else {
3349
+ const brandImport = relativeImportPath(opts.projectRoot, wiringBrandAbsPath);
3350
+ const modeImport = relativeImportPath(opts.projectRoot, modeAbsPath);
3351
+ const snippet = `@import "${brandImport}";
3352
+ @import "${modeImport}";`;
3353
+ wiring = { target: null, status: "print-only", snippet };
3354
+ log("\nCould not find a single unambiguous stylesheet to wire the import into.");
3355
+ const pasteTargets = detection.cssFiles.filter((f) => !isCssModule(f));
3356
+ if (pasteTargets.length > 0) {
3357
+ log("Paste the block for whichever of these is your global stylesheet:");
3358
+ for (const target of pasteTargets) {
3359
+ const dir = dirname7(join20(opts.projectRoot, target));
3360
+ const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
3361
+ @import "${relativeImportPath(dir, modeAbsPath)}";`;
3362
+ log(`
3363
+ ${target}:`);
3364
+ log(` ${forTarget.split("\n").join("\n ")}`);
3365
+ }
3366
+ } else {
3367
+ log(
3368
+ "Add this near the top of your global stylesheet. A CSS @import resolves relative to the file it sits in, so these project-root paths need a ../ per directory of depth:"
3369
+ );
3370
+ log(` ${snippet.split("\n").join("\n ")}`);
3371
+ }
3372
+ }
3373
+ if (isIgnored(opts.projectRoot, ".jig")) {
3374
+ log(
3375
+ "\n WARNING: .jig/ is gitignored, but it holds this project's tokens \u2014 the files your stylesheet @imports. Committed, teammates and CI get the same design system; ignored, their builds break on a missing import. Remove the .jig/ rule from .gitignore, or commit the directory explicitly."
3376
+ );
3377
+ }
3378
+ const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
3379
+ log("\nBaseline:");
3380
+ log(baselineResult.report);
3381
+ return {
3382
+ detection,
3383
+ proposal,
3384
+ validation,
3385
+ finalColor,
3386
+ // I2: report the surfaces that actually govern wiring (effectiveConfig),
3387
+ // not the freshly-derived/prompted `surfaces` local — the two differ
3388
+ // exactly when an existing config was kept, and this field should
3389
+ // describe what happened, not what would have happened on a fresh write.
3390
+ surfaces: effectiveConfig.surfaces,
3391
+ brand: { relPath: brandRelPath, action: brandAction },
3392
+ config: { relPath: configRelPath, action: configAction },
3393
+ wiring,
3394
+ baseline: { report: baselineResult.report, findingsCount: baselineResult.findings.length }
3395
+ };
3396
+ }
3397
+
3398
+ // src/index.ts
3399
+ var packageRoot = getPackageRoot();
3400
+ var { version } = JSON.parse(readFileSync16(join21(packageRoot, "package.json"), "utf8"));
3401
+ function warnIfUnpublishedPin() {
3402
+ if (isPublishedBuild(packageRoot)) return;
3403
+ console.warn(
3404
+ `Note: this is a source build, so the skill pins 'npx jig-ui@${version}' \u2014 a version that may not be published. Agents reading it will not be able to run the CLI until it is. Re-run install or update from a published build to correct the pin.`
3405
+ );
3406
+ }
3407
+ var program = new Command();
3408
+ program.name("jig").description("A design system for coding agents.").version(version);
3409
+ program.command("install").description("Install Jig rules and the agent skill file into a repository.").requiredOption("--agent <name>", `target agent (${adapterNames().join(", ")})`).option("--scope <scope>", "project or global", "project").action((opts) => {
3410
+ if (opts.scope !== "project" && opts.scope !== "global") {
3411
+ console.error(`Invalid scope '${opts.scope}'. Use 'project' or 'global'.`);
3412
+ process.exit(1);
3413
+ }
3414
+ const projectRoot = findProjectRoot(process.cwd());
3415
+ try {
3416
+ const result = install({
3417
+ agent: opts.agent,
3418
+ scope: opts.scope,
3419
+ projectRoot,
3420
+ packageRoot: assetRoot(),
3421
+ version,
3422
+ homeDir: homedir()
3423
+ });
3424
+ if (result.warning) {
3425
+ console.warn(result.warning);
3426
+ return;
3427
+ }
3428
+ console.log(`Installed Jig v${version} for ${opts.agent} (${opts.scope} scope)`);
3429
+ warnIfUnpublishedPin();
3430
+ for (const f of result.written) console.log(` + ${f}`);
3431
+ for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
3432
+ } catch (err) {
3433
+ console.error(err.message);
3434
+ process.exit(1);
3435
+ }
3436
+ });
3437
+ program.command("explain").description("Print a rule's full text, its correction, and the version it was introduced in.").argument("<rule-id>", "a rule id, e.g. 'C-19' or 'P-02'").action((ruleId) => {
3438
+ try {
3439
+ console.log(explain({ ruleId, version }));
3440
+ } catch (err) {
3441
+ console.error(err.message);
3442
+ process.exit(1);
3443
+ }
3444
+ });
3445
+ program.command("update").description("Update vendored Jig rules, skipping files you have edited.").action(() => {
3446
+ const projectRoot = findProjectRoot(process.cwd());
3447
+ try {
3448
+ const result = update({
552
3449
  agent: "",
553
3450
  scope: "project",
554
3451
  projectRoot,
@@ -556,7 +3453,11 @@ program.command("update").description("Update vendored Jig rules, skipping files
556
3453
  version,
557
3454
  homeDir: homedir()
558
3455
  });
559
- console.log(`Updated Jig ${result.fromVersion} \u2192 ${result.toVersion}`);
3456
+ const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
3457
+ warnIfUnpublishedPin();
3458
+ console.log(
3459
+ `Updated Jig \u2192 ${result.toVersion} in ${result.targets.length} harness${result.targets.length === 1 ? "" : "es"}: ${label}`
3460
+ );
560
3461
  for (const f of result.updated) console.log(` ~ ${f}`);
561
3462
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
562
3463
  } catch (err) {
@@ -564,4 +3465,36 @@ program.command("update").description("Update vendored Jig rules, skipping files
564
3465
  process.exit(1);
565
3466
  }
566
3467
  });
3468
+ program.command("check").description("Check the repo against Jig's mechanical + hybrid rules.").option("--all", "check the whole repo instead of just changed files", false).option("--ci", "mechanical bucket only; exits non-zero on any error, deterministic", false).option("--json", "emit findings as JSON", false).action((opts) => {
3469
+ const projectRoot = findProjectRoot(process.cwd());
3470
+ try {
3471
+ const result = check({
3472
+ projectRoot,
3473
+ homeDir: homedir(),
3474
+ version,
3475
+ all: opts.all,
3476
+ ci: opts.ci
3477
+ });
3478
+ console.log(opts.json ? JSON.stringify(result.findings, null, 2) : result.report);
3479
+ if (opts.ci && result.hasError) process.exit(1);
3480
+ } catch (err) {
3481
+ console.error(err.message);
3482
+ process.exit(1);
3483
+ }
3484
+ });
3485
+ program.command("init").description("Set the project up to use Jig: a brand file, jig.config.json, and a baseline check.").option("--yes", "non-interactive: derive everything, accept the proposal, ask nothing", false).action(async (opts) => {
3486
+ const projectRoot = findProjectRoot(process.cwd());
3487
+ try {
3488
+ await init({
3489
+ projectRoot,
3490
+ packageRoot: assetRoot(),
3491
+ homeDir: homedir(),
3492
+ version,
3493
+ yes: opts.yes
3494
+ });
3495
+ } catch (err) {
3496
+ console.error(err.message);
3497
+ process.exit(1);
3498
+ }
3499
+ });
567
3500
  program.parse();