jig-ui 0.3.0 → 0.5.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/README.md +258 -74
- package/dist/index.js +1589 -333
- package/package.json +2 -1
- package/rules/00-anti-patterns.md +44 -8
- package/rules/01-modes.md +8 -7
- package/rules/02-tokens.md +184 -9
- package/rules/03-patterns.md +71 -4
- package/templates/COMMAND.md.tmpl +92 -0
- package/templates/SKILL.md.tmpl +27 -12
- package/templates/command-metadata.json +6 -6
- package/tokens/brand.default.css +67 -2
- package/tokens/mode.editorial.css +45 -2
- package/tokens/mode.operator.css +17 -1
- package/tokens/mode.product.css +25 -2
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { readFileSync as
|
|
5
|
+
import { readFileSync as readFileSync17 } from "fs";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join22 } 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
|
|
43
|
-
import {
|
|
45
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
46
|
+
import { join as join7 } 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
|
|
60
|
-
return [
|
|
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
82
|
skillFiles(ctx) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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/
|
|
134
|
-
var
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 = [
|
|
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
|
|
166
|
-
import { dirname as
|
|
167
|
-
|
|
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(
|
|
204
|
-
const path =
|
|
205
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 =
|
|
224
|
-
if (!
|
|
225
|
-
return checksum(
|
|
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
|
|
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
|
-
|
|
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
|
|
458
|
+
let cursor = 0;
|
|
305
459
|
let insertAt = -1;
|
|
306
460
|
regions.forEach((region, i) => {
|
|
307
|
-
let chunk = existing.slice(
|
|
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
|
-
|
|
465
|
+
cursor = region.end;
|
|
312
466
|
});
|
|
313
|
-
const tail = existing.slice(
|
|
467
|
+
const tail = existing.slice(cursor).replace(/^\n/, "");
|
|
314
468
|
text += tail;
|
|
315
469
|
return { text, insertAt, removed: true };
|
|
316
470
|
}
|
|
@@ -323,85 +477,161 @@ function upsertBlock(existing, block) {
|
|
|
323
477
|
return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
|
|
324
478
|
}
|
|
325
479
|
|
|
326
|
-
// src/
|
|
327
|
-
|
|
480
|
+
// src/install/writer.ts
|
|
481
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
|
|
482
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
483
|
+
|
|
484
|
+
// src/install/line-endings.ts
|
|
485
|
+
function dominantEnding(existing) {
|
|
486
|
+
const crlf = (existing.match(/\r\n/g) ?? []).length;
|
|
487
|
+
const lf = (existing.match(/(?<!\r)\n/g) ?? []).length;
|
|
488
|
+
if (crlf === 0 && lf === 0) return "\n";
|
|
489
|
+
return crlf >= lf ? "\r\n" : "\n";
|
|
490
|
+
}
|
|
491
|
+
function matchLineEndings(content, existing) {
|
|
492
|
+
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
493
|
+
const target = existing === void 0 ? "\n" : dominantEnding(existing);
|
|
494
|
+
return target === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// src/install/writer.ts
|
|
328
498
|
function relKey(...parts) {
|
|
329
499
|
return parts.join("/");
|
|
330
500
|
}
|
|
331
|
-
function
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
501
|
+
function createWriter(root, seed = {}) {
|
|
502
|
+
const files = { ...seed };
|
|
503
|
+
return {
|
|
504
|
+
files,
|
|
505
|
+
write(key, content) {
|
|
506
|
+
const abs = join6(root, ...key.split("/"));
|
|
507
|
+
mkdirSync3(dirname4(abs), { recursive: true });
|
|
508
|
+
const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
|
|
509
|
+
const toWrite = matchLineEndings(content, existing);
|
|
510
|
+
writeFileAtomic(abs, toWrite);
|
|
511
|
+
files[key] = checksum(toWrite);
|
|
512
|
+
return key;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function bundleFiles(packageRoot2, dir) {
|
|
517
|
+
const ext = dir === "rules" ? ".md" : ".css";
|
|
518
|
+
return readdirSync2(join6(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
|
|
336
519
|
}
|
|
337
|
-
|
|
338
|
-
|
|
520
|
+
|
|
521
|
+
// src/commands/install.ts
|
|
522
|
+
var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
|
|
523
|
+
function buildSkillBody(packageRoot2, rulesPath, version2) {
|
|
524
|
+
const template = readFileSync4(join7(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
|
|
339
525
|
const metadata = JSON.parse(
|
|
340
|
-
|
|
526
|
+
readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
341
527
|
);
|
|
342
528
|
return render(template, {
|
|
343
529
|
command_prefix: "/jig ",
|
|
344
|
-
scripts_path: "npx jig-ui",
|
|
530
|
+
scripts_path: version2 ? `npx jig-ui@${version2}` : "npx jig-ui",
|
|
531
|
+
// `update` must NOT carry the pin. Pinned, it refreshes to the version
|
|
532
|
+
// already installed — reporting "Updated Jig → <same version>" for a no-op
|
|
533
|
+
// — so a reader following the skill could never upgrade.
|
|
534
|
+
update_path: "npx jig-ui@latest",
|
|
345
535
|
ask_instruction: ASK_INSTRUCTION,
|
|
346
536
|
available_commands: renderCommandTable(metadata),
|
|
347
537
|
config_file: "jig.config.json",
|
|
348
|
-
rules_path:
|
|
538
|
+
rules_path: rulesPath
|
|
349
539
|
});
|
|
350
540
|
}
|
|
541
|
+
function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
|
|
542
|
+
const metadata = JSON.parse(
|
|
543
|
+
readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
544
|
+
);
|
|
545
|
+
const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available").map(([name]) => name).sort();
|
|
546
|
+
const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
|
|
547
|
+
if (!existsSync5(templatePath)) return { body: "", subcommands };
|
|
548
|
+
const template = readFileSync4(templatePath, "utf8");
|
|
549
|
+
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(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath);
|
|
550
|
+
return { body, subcommands };
|
|
551
|
+
}
|
|
552
|
+
function rulesPathFor(referenceDir, scope) {
|
|
553
|
+
return scope === "global" ? `~/${referenceDir}/rules` : `${referenceDir}/rules`;
|
|
554
|
+
}
|
|
351
555
|
function install(opts) {
|
|
352
556
|
const adapter = getAdapter(opts.agent);
|
|
353
|
-
if (
|
|
354
|
-
|
|
557
|
+
if (opts.scope === "project") {
|
|
558
|
+
const globalReferenceDir = referenceDirFor(adapter, "global");
|
|
559
|
+
let globalManifest;
|
|
560
|
+
try {
|
|
561
|
+
globalManifest = readManifest(opts.homeDir, globalReferenceDir);
|
|
562
|
+
} catch {
|
|
563
|
+
globalManifest = null;
|
|
564
|
+
}
|
|
565
|
+
if (globalManifest) {
|
|
566
|
+
return {
|
|
567
|
+
written: [],
|
|
568
|
+
skipped: [],
|
|
569
|
+
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.`
|
|
570
|
+
};
|
|
571
|
+
}
|
|
355
572
|
}
|
|
356
573
|
const installRoot = opts.scope === "global" ? opts.homeDir : opts.projectRoot;
|
|
574
|
+
const referenceDir = referenceDirFor(adapter, opts.scope);
|
|
357
575
|
let existingManifest;
|
|
358
576
|
try {
|
|
359
|
-
existingManifest = readManifest(installRoot);
|
|
577
|
+
existingManifest = readManifest(installRoot, referenceDir);
|
|
360
578
|
} catch {
|
|
361
579
|
existingManifest = null;
|
|
362
580
|
}
|
|
363
581
|
const planned = [];
|
|
364
|
-
const rulesDir =
|
|
365
|
-
for (const file of
|
|
366
|
-
const body =
|
|
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");
|
|
582
|
+
const rulesDir = join7(opts.packageRoot, "rules");
|
|
583
|
+
for (const file of bundleFiles(opts.packageRoot, "rules")) {
|
|
584
|
+
const body = readFileSync4(join7(rulesDir, file), "utf8");
|
|
372
585
|
planned.push({
|
|
373
|
-
key: relKey(
|
|
374
|
-
content: vendorHeader(file, opts.version, "
|
|
586
|
+
key: relKey(referenceDir, "rules", file),
|
|
587
|
+
content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
|
|
588
|
+
checkable: true
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
for (const ref of referenceFiles(opts.packageRoot)) {
|
|
592
|
+
planned.push({
|
|
593
|
+
key: relKey(referenceDir, ...ref.relPath.split("/")),
|
|
594
|
+
content: vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content,
|
|
375
595
|
checkable: true
|
|
376
596
|
});
|
|
377
597
|
}
|
|
378
598
|
planned.push({
|
|
379
|
-
key: relKey(
|
|
380
|
-
content:
|
|
599
|
+
key: relKey(referenceDir, "rules.index.json"),
|
|
600
|
+
content: readFileSync4(join7(opts.packageRoot, "rules.index.json"), "utf8"),
|
|
381
601
|
checkable: true
|
|
382
602
|
});
|
|
383
603
|
for (const src of ["LICENSE", "NOTICE"]) {
|
|
384
604
|
planned.push({
|
|
385
|
-
key: relKey(
|
|
386
|
-
content:
|
|
605
|
+
key: relKey(referenceDir, src),
|
|
606
|
+
content: readFileSync4(join7(opts.packageRoot, src), "utf8"),
|
|
387
607
|
checkable: false
|
|
388
608
|
});
|
|
389
609
|
}
|
|
390
|
-
const
|
|
610
|
+
const rulesPath = rulesPathFor(referenceDir, opts.scope);
|
|
611
|
+
const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
|
|
612
|
+
const { body: commandBody, subcommands } = buildCommandBody(
|
|
613
|
+
opts.packageRoot,
|
|
614
|
+
rulesPath,
|
|
615
|
+
opts.version,
|
|
616
|
+
adapter.argsPlaceholder ?? "$ARGUMENTS"
|
|
617
|
+
);
|
|
618
|
+
const command = { commandBody, subcommands };
|
|
391
619
|
const skillFiles = skillFilesFor(adapter, {
|
|
392
620
|
version: opts.version,
|
|
393
621
|
scope: opts.scope,
|
|
394
622
|
skillBody,
|
|
395
|
-
commandPrefix: "/jig "
|
|
623
|
+
commandPrefix: "/jig ",
|
|
624
|
+
...command
|
|
396
625
|
});
|
|
397
626
|
for (const file of skillFiles) {
|
|
398
627
|
const key = file.relPath;
|
|
399
|
-
const abs =
|
|
628
|
+
const abs = join7(installRoot, ...key.split("/"));
|
|
400
629
|
const isBlockFile = file.content.includes(BLOCK_START);
|
|
401
|
-
const content = isBlockFile &&
|
|
630
|
+
const content = isBlockFile && existsSync5(abs) ? upsertBlock(readFileSync4(abs, "utf8"), file.content) : file.content;
|
|
402
631
|
planned.push({ key, content, checkable: !isBlockFile });
|
|
403
632
|
}
|
|
404
|
-
const
|
|
633
|
+
const writer = createWriter(installRoot);
|
|
634
|
+
const files = writer.files;
|
|
405
635
|
const written = [];
|
|
406
636
|
const skipped = [];
|
|
407
637
|
for (const { key, content, checkable } of planned) {
|
|
@@ -411,7 +641,7 @@ function install(opts) {
|
|
|
411
641
|
if (recorded) files[key] = recorded;
|
|
412
642
|
continue;
|
|
413
643
|
}
|
|
414
|
-
|
|
644
|
+
writer.write(key, content);
|
|
415
645
|
written.push(key);
|
|
416
646
|
}
|
|
417
647
|
const manifest = {
|
|
@@ -421,118 +651,291 @@ function install(opts) {
|
|
|
421
651
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
422
652
|
files
|
|
423
653
|
};
|
|
424
|
-
writeManifest(installRoot, manifest);
|
|
425
|
-
written.push(relKey(
|
|
654
|
+
writeManifest(installRoot, manifest, referenceDir);
|
|
655
|
+
written.push(relKey(referenceDir, "manifest.json"));
|
|
426
656
|
return { written, skipped };
|
|
427
657
|
}
|
|
428
658
|
|
|
429
659
|
// src/commands/update.ts
|
|
430
|
-
import {
|
|
431
|
-
import { existsSync as
|
|
660
|
+
import { join as join10 } from "path";
|
|
661
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
|
|
432
662
|
|
|
433
663
|
// src/install/target.ts
|
|
434
664
|
import { resolve } from "path";
|
|
435
|
-
function
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
if (!existing) return null;
|
|
665
|
+
function resolveAllInstalled(projectRoot, homeDir) {
|
|
666
|
+
const found = [];
|
|
667
|
+
const seen = /* @__PURE__ */ new Set();
|
|
439
668
|
const sameRoot = resolve(projectRoot) === resolve(homeDir);
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
669
|
+
for (const adapter of ADAPTERS) {
|
|
670
|
+
const referenceDir = referenceDirFor(adapter, "project");
|
|
671
|
+
const manifest = readManifest(projectRoot, referenceDir);
|
|
672
|
+
if (manifest && manifest.agent !== adapter.name) {
|
|
673
|
+
getAdapter(manifest.agent);
|
|
674
|
+
}
|
|
675
|
+
if (manifest && manifest.agent === adapter.name) {
|
|
676
|
+
const collides = sameRoot && referenceDirFor(adapter, "global") === referenceDir;
|
|
677
|
+
const scope = collides ? manifest.scope : "project";
|
|
678
|
+
const installRoot = scope === "global" ? homeDir : projectRoot;
|
|
679
|
+
const key = `${resolve(installRoot)}\0${referenceDir}`;
|
|
680
|
+
if (!seen.has(key)) {
|
|
681
|
+
seen.add(key);
|
|
682
|
+
found.push({ installRoot, scope, manifest, referenceDir });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
for (const adapter of ADAPTERS) {
|
|
687
|
+
const referenceDir = referenceDirFor(adapter, "global");
|
|
688
|
+
const manifest = readManifest(homeDir, referenceDir);
|
|
689
|
+
if (manifest && manifest.agent !== adapter.name) {
|
|
690
|
+
getAdapter(manifest.agent);
|
|
691
|
+
}
|
|
692
|
+
if (manifest && manifest.agent === adapter.name) {
|
|
693
|
+
const key = `${resolve(homeDir)}\0${referenceDir}`;
|
|
694
|
+
if (!seen.has(key)) {
|
|
695
|
+
seen.add(key);
|
|
696
|
+
found.push({ installRoot: homeDir, scope: "global", manifest, referenceDir });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return found;
|
|
443
701
|
}
|
|
444
702
|
|
|
445
703
|
// src/init/state.ts
|
|
446
|
-
import { existsSync as
|
|
447
|
-
import { dirname as
|
|
448
|
-
var INIT_MANIFEST_REL =
|
|
704
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync5 } from "fs";
|
|
705
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
706
|
+
var INIT_MANIFEST_REL = join8(".jig", "state.json");
|
|
449
707
|
function readInitManifest(projectRoot) {
|
|
450
|
-
const path =
|
|
451
|
-
if (!
|
|
708
|
+
const path = join8(projectRoot, INIT_MANIFEST_REL);
|
|
709
|
+
if (!existsSync6(path)) return null;
|
|
452
710
|
try {
|
|
453
|
-
const raw = JSON.parse(
|
|
711
|
+
const raw = JSON.parse(readFileSync5(path, "utf8"));
|
|
454
712
|
if (typeof raw !== "object" || raw === null) return null;
|
|
455
|
-
const files = raw
|
|
713
|
+
const { files, version: version2, modes } = raw;
|
|
456
714
|
if (typeof files !== "object" || files === null) return null;
|
|
457
|
-
return {
|
|
715
|
+
return {
|
|
716
|
+
files,
|
|
717
|
+
version: typeof version2 === "string" ? version2 : void 0,
|
|
718
|
+
modes: Array.isArray(modes) && modes.every((m) => typeof m === "string") ? modes : void 0
|
|
719
|
+
};
|
|
458
720
|
} catch {
|
|
459
721
|
return null;
|
|
460
722
|
}
|
|
461
723
|
}
|
|
724
|
+
var MERGE_ATTEMPTS2 = 5;
|
|
462
725
|
function writeInitManifest(projectRoot, m) {
|
|
463
|
-
const path =
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
726
|
+
const path = join8(projectRoot, INIT_MANIFEST_REL);
|
|
727
|
+
mkdirSync4(dirname5(path), { recursive: true });
|
|
728
|
+
for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
|
|
729
|
+
const onDisk = readInitManifest(projectRoot);
|
|
730
|
+
const merged = {
|
|
731
|
+
...m,
|
|
732
|
+
modes: [.../* @__PURE__ */ new Set([...onDisk?.modes ?? [], ...m.modes ?? []])],
|
|
733
|
+
files: { ...onDisk?.files ?? {}, ...m.files }
|
|
734
|
+
};
|
|
735
|
+
writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
|
|
736
|
+
`);
|
|
737
|
+
const after = readInitManifest(projectRoot);
|
|
738
|
+
if (after && Object.keys(merged.files).every((k) => k in after.files)) return;
|
|
739
|
+
}
|
|
467
740
|
}
|
|
468
741
|
function isInitFileModified(projectRoot, relPath, m) {
|
|
469
742
|
const asManifest = { version: "", agent: "", scope: "project", installedAt: "", files: m.files };
|
|
470
743
|
return isModified(projectRoot, relPath, asManifest);
|
|
471
744
|
}
|
|
472
745
|
|
|
746
|
+
// src/init/migrate.ts
|
|
747
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync } from "fs";
|
|
748
|
+
import { join as join9 } from "path";
|
|
749
|
+
var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
|
|
750
|
+
function isLegacyInstallArtifact(name) {
|
|
751
|
+
return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
|
|
752
|
+
}
|
|
753
|
+
function readLegacyManifest(dirAbs) {
|
|
754
|
+
try {
|
|
755
|
+
const raw = JSON.parse(readFileSync6(join9(dirAbs, "manifest.json"), "utf8"));
|
|
756
|
+
if (raw && typeof raw === "object" && typeof raw.files === "object") {
|
|
757
|
+
return raw;
|
|
758
|
+
}
|
|
759
|
+
} catch {
|
|
760
|
+
}
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
function classify(projectRoot, relPath, legacyManifest) {
|
|
764
|
+
return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
|
|
765
|
+
}
|
|
766
|
+
function detectLegacyRules(projectRoot) {
|
|
767
|
+
const jigDir = join9(projectRoot, ".jig");
|
|
768
|
+
if (!existsSync7(jigDir)) return { present: false, files: [] };
|
|
769
|
+
const legacyManifest = readLegacyManifest(jigDir);
|
|
770
|
+
let entries;
|
|
771
|
+
try {
|
|
772
|
+
entries = readdirSync3(jigDir);
|
|
773
|
+
} catch {
|
|
774
|
+
entries = [];
|
|
775
|
+
}
|
|
776
|
+
const files = entries.filter(isLegacyInstallArtifact).sort().map((name) => {
|
|
777
|
+
const relPath = `.jig/${name}`;
|
|
778
|
+
return { relPath, modified: classify(projectRoot, relPath, legacyManifest) };
|
|
779
|
+
});
|
|
780
|
+
return { present: files.length > 0, files };
|
|
781
|
+
}
|
|
782
|
+
function detectLegacyCursorRules(projectRoot) {
|
|
783
|
+
const bundleDirRel = ".cursor/rules/jig";
|
|
784
|
+
const bundleDir = join9(projectRoot, ...bundleDirRel.split("/"));
|
|
785
|
+
const mdcRel = ".cursor/rules/jig.mdc";
|
|
786
|
+
const mdcAbs = join9(projectRoot, ...mdcRel.split("/"));
|
|
787
|
+
const legacyManifest = readLegacyManifest(bundleDir);
|
|
788
|
+
const files = [];
|
|
789
|
+
if (existsSync7(mdcAbs)) {
|
|
790
|
+
files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
|
|
791
|
+
}
|
|
792
|
+
let entries;
|
|
793
|
+
try {
|
|
794
|
+
entries = readdirSync3(bundleDir);
|
|
795
|
+
} catch {
|
|
796
|
+
entries = [];
|
|
797
|
+
}
|
|
798
|
+
for (const name of entries.filter(isLegacyInstallArtifact).sort()) {
|
|
799
|
+
const relPath = `${bundleDirRel}/${name}`;
|
|
800
|
+
files.push({ relPath, modified: classify(projectRoot, relPath, legacyManifest) });
|
|
801
|
+
}
|
|
802
|
+
return { present: files.length > 0, files };
|
|
803
|
+
}
|
|
804
|
+
function describeLegacyFiles(report, headerLine, subheaderLine) {
|
|
805
|
+
if (!report.present) return [];
|
|
806
|
+
const lines = [];
|
|
807
|
+
lines.push(`
|
|
808
|
+
${headerLine}`);
|
|
809
|
+
lines.push(subheaderLine);
|
|
810
|
+
for (const f of report.files) lines.push(` ${f.relPath}`);
|
|
811
|
+
const edited = report.files.filter((f) => f.modified === true).map((f) => f.relPath);
|
|
812
|
+
const unknown = report.files.filter((f) => f.modified === "unknown").map((f) => f.relPath);
|
|
813
|
+
if (edited.length) {
|
|
814
|
+
lines.push(" Edited since install \u2014 kept, never removed automatically:");
|
|
815
|
+
for (const relPath of edited) lines.push(` ${relPath}`);
|
|
816
|
+
}
|
|
817
|
+
if (unknown.length) {
|
|
818
|
+
lines.push(" No install record to verify against \u2014 kept; remove manually if you are sure they are untouched:");
|
|
819
|
+
for (const relPath of unknown) lines.push(` ${relPath}`);
|
|
820
|
+
}
|
|
821
|
+
return lines;
|
|
822
|
+
}
|
|
823
|
+
function describeLegacyReport(report) {
|
|
824
|
+
return describeLegacyFiles(
|
|
825
|
+
report,
|
|
826
|
+
`Found a pre-0.4.0 Jig install in .jig/ (${report.files.length} file(s)). These are no longer used \u2014`,
|
|
827
|
+
" since 0.4.0 the skill and its rules live in your agent's skill directory, not the project:"
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
function describeLegacyCursorReport(report) {
|
|
831
|
+
return describeLegacyFiles(
|
|
832
|
+
report,
|
|
833
|
+
`Found a legacy Cursor install (${report.files.length} file(s)). Cursor's skill has moved \u2014`,
|
|
834
|
+
" from .cursor/rules/jig.mdc to .cursor/skills/jig/SKILL.md; these are no longer read:"
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
function removableLegacyFiles(report) {
|
|
838
|
+
return report.files.filter((f) => f.modified === false).map((f) => f.relPath);
|
|
839
|
+
}
|
|
840
|
+
function removeLegacyFiles(projectRoot, relPaths) {
|
|
841
|
+
for (const relPath of relPaths) {
|
|
842
|
+
try {
|
|
843
|
+
rmSync(join9(projectRoot, ...relPath.split("/")), { force: true });
|
|
844
|
+
} catch {
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
473
849
|
// src/commands/update.ts
|
|
474
|
-
var ALWAYS_REPLACE = [relKey(".jig", "LICENSE"), relKey(".jig", "NOTICE")];
|
|
475
850
|
function update(opts) {
|
|
476
|
-
const resolved =
|
|
477
|
-
if (
|
|
851
|
+
const resolved = resolveAllInstalled(opts.projectRoot, opts.homeDir);
|
|
852
|
+
if (resolved.length === 0) {
|
|
853
|
+
const legacy = detectLegacyRules(opts.projectRoot);
|
|
854
|
+
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.` : "";
|
|
478
855
|
throw new Error(
|
|
479
|
-
`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first
|
|
856
|
+
`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.${hint}`
|
|
480
857
|
);
|
|
481
858
|
}
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
);
|
|
859
|
+
const updated = [];
|
|
860
|
+
const skipped = [];
|
|
861
|
+
const targets = [];
|
|
862
|
+
for (const target of resolved) {
|
|
863
|
+
const one = updateTarget(opts, target);
|
|
864
|
+
updated.push(...one.updated);
|
|
865
|
+
skipped.push(...one.skipped);
|
|
866
|
+
targets.push({
|
|
867
|
+
agent: target.manifest.agent,
|
|
868
|
+
scope: target.scope,
|
|
869
|
+
referenceDir: target.referenceDir,
|
|
870
|
+
fromVersion: target.manifest.version
|
|
871
|
+
});
|
|
488
872
|
}
|
|
873
|
+
const initResult = updateInitFiles(opts);
|
|
874
|
+
updated.push(...initResult.updated);
|
|
875
|
+
skipped.push(...initResult.skipped);
|
|
876
|
+
return {
|
|
877
|
+
updated,
|
|
878
|
+
skipped,
|
|
879
|
+
fromVersion: resolved[0].manifest.version,
|
|
880
|
+
toVersion: opts.version,
|
|
881
|
+
targets
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
function updateTarget(opts, resolved) {
|
|
885
|
+
const { installRoot, scope: discoveredScope, manifest: existing, referenceDir } = resolved;
|
|
886
|
+
const adapter = getAdapter(existing.agent);
|
|
489
887
|
const updated = [];
|
|
490
888
|
const skipped = [];
|
|
491
|
-
const
|
|
889
|
+
const writer = createWriter(installRoot, existing.files);
|
|
890
|
+
const files = writer.files;
|
|
492
891
|
const write = (key, content) => {
|
|
493
|
-
|
|
494
|
-
mkdirSync4(dirname5(abs), { recursive: true });
|
|
495
|
-
writeFileSync4(abs, content, "utf8");
|
|
496
|
-
files[key] = checksum(content);
|
|
497
|
-
updated.push(key);
|
|
892
|
+
updated.push(writer.write(key, content));
|
|
498
893
|
};
|
|
499
|
-
const rulesDir =
|
|
500
|
-
for (const file of
|
|
501
|
-
const key = relKey("
|
|
894
|
+
const rulesDir = join10(opts.packageRoot, "rules");
|
|
895
|
+
for (const file of bundleFiles(opts.packageRoot, "rules")) {
|
|
896
|
+
const key = relKey(referenceDir, "rules", file);
|
|
502
897
|
if (isModified(installRoot, key, existing)) {
|
|
503
898
|
skipped.push(key);
|
|
504
899
|
continue;
|
|
505
900
|
}
|
|
506
|
-
write(key, vendorHeader(file, opts.version) +
|
|
901
|
+
write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync7(join10(rulesDir, file), "utf8"));
|
|
507
902
|
}
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
const key = relKey(".jig", "tokens", file);
|
|
903
|
+
for (const ref of referenceFiles(opts.packageRoot)) {
|
|
904
|
+
const key = relKey(referenceDir, ...ref.relPath.split("/"));
|
|
511
905
|
if (isModified(installRoot, key, existing)) {
|
|
512
906
|
skipped.push(key);
|
|
513
907
|
continue;
|
|
514
908
|
}
|
|
515
|
-
write(key, vendorHeader(
|
|
909
|
+
write(key, vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content);
|
|
516
910
|
}
|
|
517
|
-
const indexKey = relKey(
|
|
911
|
+
const indexKey = relKey(referenceDir, "rules.index.json");
|
|
518
912
|
if (isModified(installRoot, indexKey, existing)) {
|
|
519
913
|
skipped.push(indexKey);
|
|
520
914
|
} else {
|
|
521
|
-
write(indexKey,
|
|
522
|
-
}
|
|
523
|
-
const
|
|
915
|
+
write(indexKey, readFileSync7(join10(opts.packageRoot, "rules.index.json"), "utf8"));
|
|
916
|
+
}
|
|
917
|
+
const rulesPath = rulesPathFor(referenceDir, discoveredScope);
|
|
918
|
+
const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
|
|
919
|
+
const { body: commandBody, subcommands } = buildCommandBody(
|
|
920
|
+
opts.packageRoot,
|
|
921
|
+
rulesPath,
|
|
922
|
+
opts.version,
|
|
923
|
+
adapter.argsPlaceholder ?? "$ARGUMENTS"
|
|
924
|
+
);
|
|
925
|
+
const command = { commandBody, subcommands };
|
|
524
926
|
const skillFiles = skillFilesFor(adapter, {
|
|
525
927
|
version: opts.version,
|
|
526
928
|
scope: discoveredScope,
|
|
527
929
|
skillBody,
|
|
528
|
-
commandPrefix: "/jig "
|
|
930
|
+
commandPrefix: "/jig ",
|
|
931
|
+
...command
|
|
529
932
|
});
|
|
530
933
|
for (const file of skillFiles) {
|
|
531
934
|
const key = file.relPath;
|
|
532
935
|
const isBlockFile = file.content.includes(BLOCK_START);
|
|
533
936
|
if (isBlockFile) {
|
|
534
|
-
const abs =
|
|
535
|
-
const current =
|
|
937
|
+
const abs = join10(installRoot, ...key.split("/"));
|
|
938
|
+
const current = existsSync8(abs) ? readFileSync7(abs, "utf8") : "";
|
|
536
939
|
write(key, upsertBlock(current, file.content));
|
|
537
940
|
continue;
|
|
538
941
|
}
|
|
@@ -542,44 +945,88 @@ function update(opts) {
|
|
|
542
945
|
}
|
|
543
946
|
write(key, file.content);
|
|
544
947
|
}
|
|
545
|
-
for (const
|
|
546
|
-
write(
|
|
948
|
+
for (const src of ["LICENSE", "NOTICE"]) {
|
|
949
|
+
write(relKey(referenceDir, src), readFileSync7(join10(opts.packageRoot, src), "utf8"));
|
|
547
950
|
}
|
|
951
|
+
const manifest = {
|
|
952
|
+
...existing,
|
|
953
|
+
scope: discoveredScope,
|
|
954
|
+
version: opts.version,
|
|
955
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
956
|
+
files
|
|
957
|
+
};
|
|
958
|
+
writeManifest(installRoot, manifest, referenceDir);
|
|
959
|
+
return { updated, skipped };
|
|
960
|
+
}
|
|
961
|
+
function updateInitFiles(opts) {
|
|
962
|
+
const updated = [];
|
|
963
|
+
const skipped = [];
|
|
964
|
+
const tokensDir = join10(opts.packageRoot, "tokens");
|
|
548
965
|
const initManifest = readInitManifest(opts.projectRoot);
|
|
549
966
|
if (initManifest) {
|
|
550
967
|
const initFiles = { ...initManifest.files };
|
|
551
968
|
let initChanged = false;
|
|
552
|
-
|
|
969
|
+
const writer = createWriter(opts.projectRoot, initFiles);
|
|
970
|
+
for (const file of bundleFiles(opts.packageRoot, "tokens")) {
|
|
553
971
|
const key = relKey(".jig", "tokens", file);
|
|
554
972
|
if (!(key in initManifest.files)) continue;
|
|
555
973
|
if (isInitFileModified(opts.projectRoot, key, initManifest)) {
|
|
556
974
|
skipped.push(key);
|
|
557
975
|
continue;
|
|
558
976
|
}
|
|
559
|
-
const content = vendorHeader(file, opts.version, "css") +
|
|
560
|
-
|
|
561
|
-
mkdirSync4(dirname5(abs), { recursive: true });
|
|
562
|
-
writeFileSync4(abs, content, "utf8");
|
|
563
|
-
initFiles[key] = checksum(content);
|
|
977
|
+
const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
|
|
978
|
+
updated.push(writer.write(key, content));
|
|
564
979
|
initChanged = true;
|
|
565
|
-
updated.push(key);
|
|
566
980
|
}
|
|
567
|
-
|
|
981
|
+
Object.assign(initFiles, writer.files);
|
|
982
|
+
if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
|
|
568
983
|
}
|
|
569
|
-
|
|
570
|
-
...existing,
|
|
571
|
-
scope: discoveredScope,
|
|
572
|
-
version: opts.version,
|
|
573
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
574
|
-
files
|
|
575
|
-
};
|
|
576
|
-
writeManifest(installRoot, manifest);
|
|
577
|
-
return { updated, skipped, fromVersion: existing.version, toVersion: opts.version };
|
|
984
|
+
return { updated, skipped };
|
|
578
985
|
}
|
|
579
986
|
|
|
580
|
-
// src/commands/
|
|
581
|
-
import {
|
|
582
|
-
|
|
987
|
+
// src/commands/explain.ts
|
|
988
|
+
import { join as join13 } from "path";
|
|
989
|
+
|
|
990
|
+
// src/rules/load.ts
|
|
991
|
+
import { readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
|
|
992
|
+
import { join as join11 } from "path";
|
|
993
|
+
|
|
994
|
+
// src/rules/parse.ts
|
|
995
|
+
var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
|
|
996
|
+
function parseRules(markdown, sourceFile) {
|
|
997
|
+
const lines = markdown.split("\n");
|
|
998
|
+
const rules = [];
|
|
999
|
+
let current = null;
|
|
1000
|
+
const push = () => {
|
|
1001
|
+
if (current) rules.push(current);
|
|
1002
|
+
};
|
|
1003
|
+
for (const line of lines) {
|
|
1004
|
+
const heading = HEADING.exec(line);
|
|
1005
|
+
if (heading) {
|
|
1006
|
+
push();
|
|
1007
|
+
const [, section, num, title] = heading;
|
|
1008
|
+
const id = `${section}-${num}`;
|
|
1009
|
+
current = {
|
|
1010
|
+
id,
|
|
1011
|
+
section,
|
|
1012
|
+
number: Number(num),
|
|
1013
|
+
title,
|
|
1014
|
+
wrong: "",
|
|
1015
|
+
correction: "",
|
|
1016
|
+
source: `${sourceFile}#${id.toLowerCase()}`
|
|
1017
|
+
};
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (!current) continue;
|
|
1021
|
+
if (line.startsWith("\u274C") && !current.wrong) {
|
|
1022
|
+
current.wrong = line.slice(1).trim();
|
|
1023
|
+
} else if (line.startsWith("\u2705") && !current.correction) {
|
|
1024
|
+
current.correction = line.slice(1).trim();
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
push();
|
|
1028
|
+
return rules;
|
|
1029
|
+
}
|
|
583
1030
|
|
|
584
1031
|
// src/rules/schema.ts
|
|
585
1032
|
var BUCKETS = ["mechanical", "judgment", "hybrid"];
|
|
@@ -612,10 +1059,183 @@ function validateIndex(entries) {
|
|
|
612
1059
|
});
|
|
613
1060
|
}
|
|
614
1061
|
|
|
1062
|
+
// src/rules/load.ts
|
|
1063
|
+
function loadRules(rulesDir, indexPath) {
|
|
1064
|
+
const files = readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort();
|
|
1065
|
+
const parsed = [];
|
|
1066
|
+
for (const file of files) {
|
|
1067
|
+
parsed.push(...parseRules(readFileSync8(join11(rulesDir, file), "utf8"), file));
|
|
1068
|
+
}
|
|
1069
|
+
const entries = validateIndex(JSON.parse(readFileSync8(indexPath, "utf8")));
|
|
1070
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
1071
|
+
const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
|
|
1072
|
+
if (missing.length) {
|
|
1073
|
+
throw new Error(
|
|
1074
|
+
`Rules present in markdown but missing from rules.index.json: ${missing.join(", ")}`
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
const parsedIds = new Set(parsed.map((r) => r.id));
|
|
1078
|
+
const orphans = entries.filter((e) => !parsedIds.has(e.id)).map((e) => e.id);
|
|
1079
|
+
if (orphans.length) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`Entries in rules.index.json with no matching rule: ${orphans.join(", ")}`
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
return parsed.map((r) => {
|
|
1085
|
+
const { id: _id, ...meta } = byId.get(r.id);
|
|
1086
|
+
return { ...r, ...meta };
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// src/rules/specs.ts
|
|
1091
|
+
import { readFileSync as readFileSync9, readdirSync as readdirSync5 } from "fs";
|
|
1092
|
+
import { join as join12 } from "path";
|
|
1093
|
+
var HEADING2 = /^##\s+([PM]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
|
|
1094
|
+
function parseSpecs(markdown, sourceFile) {
|
|
1095
|
+
const lines = markdown.split("\n");
|
|
1096
|
+
const specs = [];
|
|
1097
|
+
let current = null;
|
|
1098
|
+
const body = [];
|
|
1099
|
+
const push = () => {
|
|
1100
|
+
if (!current) return;
|
|
1101
|
+
specs.push({ ...current, body: body.join("\n").replace(/\n{3,}/g, "\n\n").trim() });
|
|
1102
|
+
body.length = 0;
|
|
1103
|
+
};
|
|
1104
|
+
for (const line of lines) {
|
|
1105
|
+
const heading = HEADING2.exec(line);
|
|
1106
|
+
if (heading) {
|
|
1107
|
+
push();
|
|
1108
|
+
const [, id, title] = heading;
|
|
1109
|
+
current = { id, title, body: "", source: `${sourceFile}#${id.toLowerCase()}` };
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
if (current && /^##\s/.test(line)) {
|
|
1113
|
+
push();
|
|
1114
|
+
current = null;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
if (current) body.push(line);
|
|
1118
|
+
}
|
|
1119
|
+
push();
|
|
1120
|
+
return specs;
|
|
1121
|
+
}
|
|
1122
|
+
function loadSpecs(rulesDir) {
|
|
1123
|
+
const specs = [];
|
|
1124
|
+
for (const file of readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
1125
|
+
specs.push(...parseSpecs(readFileSync9(join12(rulesDir, file), "utf8"), file));
|
|
1126
|
+
}
|
|
1127
|
+
return specs;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// src/commands/explain.ts
|
|
1131
|
+
var ID_SHAPE = /^([A-Z])-?(\d+)$/;
|
|
1132
|
+
var SECTION_SHAPE = /^([A-Z])$/;
|
|
1133
|
+
var matches = (haystack, needle) => haystack.toLowerCase().includes(needle.toLowerCase());
|
|
1134
|
+
var byNumber = (a, b) => {
|
|
1135
|
+
const [as, an] = a.split("-");
|
|
1136
|
+
const [bs, bn] = b.split("-");
|
|
1137
|
+
return as === bs ? Number(an) - Number(bn) : as.localeCompare(bs);
|
|
1138
|
+
};
|
|
1139
|
+
function renderRule(rule) {
|
|
1140
|
+
return [
|
|
1141
|
+
`${rule.id} ${rule.title}`,
|
|
1142
|
+
"",
|
|
1143
|
+
`\u274C ${rule.wrong}`,
|
|
1144
|
+
`\u2705 ${rule.correction}`,
|
|
1145
|
+
"",
|
|
1146
|
+
` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
|
|
1147
|
+
` since ${rule.since} \xB7 ${rule.source}`
|
|
1148
|
+
].join("\n");
|
|
1149
|
+
}
|
|
1150
|
+
function renderSpec(spec) {
|
|
1151
|
+
return [
|
|
1152
|
+
`${spec.id} ${spec.title}`,
|
|
1153
|
+
"",
|
|
1154
|
+
spec.body,
|
|
1155
|
+
"",
|
|
1156
|
+
` specification \u2014 component anatomy and behaviour, not a single rule,`,
|
|
1157
|
+
` so it carries no \u274C/\u2705 pair and no detector.`,
|
|
1158
|
+
` ${spec.source}`
|
|
1159
|
+
].join("\n");
|
|
1160
|
+
}
|
|
1161
|
+
function explain(opts) {
|
|
1162
|
+
const raw = opts.ruleId.trim().toUpperCase();
|
|
1163
|
+
const root = opts.packageRoot ?? assetRoot();
|
|
1164
|
+
const rulesDir = join13(root, "rules");
|
|
1165
|
+
const rules = loadRules(rulesDir, join13(root, "rules.index.json"));
|
|
1166
|
+
const specs = loadSpecs(rulesDir);
|
|
1167
|
+
const entries = [
|
|
1168
|
+
...rules.map((r) => ({ id: r.id, title: r.title, text: `${r.wrong}
|
|
1169
|
+
${r.correction}` })),
|
|
1170
|
+
...specs.map((s) => ({ id: s.id, title: s.title, text: s.body }))
|
|
1171
|
+
];
|
|
1172
|
+
if (opts.list) {
|
|
1173
|
+
const section = SECTION_SHAPE.exec(raw)?.[1];
|
|
1174
|
+
const shown = entries.filter((e) => !section || e.id.startsWith(`${section}-`)).sort((a, b) => byNumber(a.id, b.id));
|
|
1175
|
+
if (shown.length === 0) {
|
|
1176
|
+
throw new Error(`No section '${raw}'. Sections: ${sectionsOf(entries).join(", ")}.`);
|
|
1177
|
+
}
|
|
1178
|
+
return [
|
|
1179
|
+
section ? `Section ${section} \u2014 ${shown.length} entries` : `${shown.length} rules and specs`,
|
|
1180
|
+
"",
|
|
1181
|
+
...shown.map((e) => `${e.id.padEnd(6)} ${e.title}`)
|
|
1182
|
+
].join("\n");
|
|
1183
|
+
}
|
|
1184
|
+
const shape = ID_SHAPE.exec(raw);
|
|
1185
|
+
if (shape) {
|
|
1186
|
+
const id = `${shape[1]}-${shape[2]}`;
|
|
1187
|
+
const rule = rules.find((r) => r.id === id);
|
|
1188
|
+
if (rule) return renderRule(rule);
|
|
1189
|
+
const spec = specs.find((s) => s.id === id);
|
|
1190
|
+
if (spec) return renderSpec(spec);
|
|
1191
|
+
const siblings = entries.filter((e) => e.id.startsWith(`${shape[1]}-`)).map((e) => e.id).sort(byNumber);
|
|
1192
|
+
throw new Error(
|
|
1193
|
+
siblings.length > 0 ? `No rule '${id}'. Section ${shape[1]} has: ${siblings.join(", ")}.` : `No rule '${id}', and no section '${shape[1]}' exists.`
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
if (raw === "") {
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
`Nothing to explain. Give a rule id ('C-19'), a word to search for ('contrast'), or --list.`
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
const needle = opts.ruleId.trim();
|
|
1202
|
+
const inTitle = entries.filter((e) => matches(e.title, needle) || matches(e.id, needle));
|
|
1203
|
+
const inBody = entries.filter((e) => !inTitle.includes(e) && matches(e.text, needle));
|
|
1204
|
+
const hits = [
|
|
1205
|
+
...inTitle.sort((a, b) => byNumber(a.id, b.id)),
|
|
1206
|
+
...inBody.sort((a, b) => byNumber(a.id, b.id))
|
|
1207
|
+
];
|
|
1208
|
+
if (hits.length === 0) {
|
|
1209
|
+
throw new Error(
|
|
1210
|
+
`No rule matches '${needle}'. Try --list to see every id, or a broader word.`
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
if (hits.length === 1) {
|
|
1214
|
+
const rule = rules.find((r) => r.id === hits[0].id);
|
|
1215
|
+
return rule ? renderRule(rule) : renderSpec(specs.find((s) => s.id === hits[0].id));
|
|
1216
|
+
}
|
|
1217
|
+
return [
|
|
1218
|
+
// Title matches lead, so the list is ranked rather than sorted. Say so:
|
|
1219
|
+
// otherwise C-19 sitting above A-02 reads as a sorting bug.
|
|
1220
|
+
`${hits.length} entries match '${needle}' \u2014 title matches first:`,
|
|
1221
|
+
"",
|
|
1222
|
+
...hits.map((e) => `${e.id.padEnd(6)} ${e.title}`),
|
|
1223
|
+
"",
|
|
1224
|
+
`Run 'jig explain <id>' for any of them.`
|
|
1225
|
+
].join("\n");
|
|
1226
|
+
}
|
|
1227
|
+
function sectionsOf(entries) {
|
|
1228
|
+
return [...new Set(entries.map((e) => e.id.split("-")[0]))].sort();
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// src/commands/check.ts
|
|
1232
|
+
import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
|
|
1233
|
+
import { join as join17 } from "path";
|
|
1234
|
+
|
|
615
1235
|
// src/check/files.ts
|
|
616
1236
|
import { execFileSync } from "child_process";
|
|
617
|
-
import { readdirSync as
|
|
618
|
-
import { join as
|
|
1237
|
+
import { readdirSync as readdirSync6, realpathSync, statSync } from "fs";
|
|
1238
|
+
import { join as join14, relative, sep } from "path";
|
|
619
1239
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
620
1240
|
"node_modules",
|
|
621
1241
|
"dist",
|
|
@@ -671,19 +1291,38 @@ function gitChangedFiles(root) {
|
|
|
671
1291
|
function isExcluded(relPath) {
|
|
672
1292
|
return relPath.split("/").some((segment) => EXCLUDE_DIRS.has(segment));
|
|
673
1293
|
}
|
|
674
|
-
function walk(root, dir, out) {
|
|
1294
|
+
function walk(root, dir, out, seen) {
|
|
675
1295
|
let entries;
|
|
676
1296
|
try {
|
|
677
|
-
entries =
|
|
1297
|
+
entries = readdirSync6(dir, { withFileTypes: true });
|
|
678
1298
|
} catch {
|
|
679
1299
|
return;
|
|
680
1300
|
}
|
|
681
1301
|
for (const entry of entries) {
|
|
682
1302
|
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
683
|
-
const abs =
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1303
|
+
const abs = join14(dir, entry.name);
|
|
1304
|
+
let isDir = entry.isDirectory();
|
|
1305
|
+
let isFile = entry.isFile();
|
|
1306
|
+
if (entry.isSymbolicLink()) {
|
|
1307
|
+
try {
|
|
1308
|
+
const target = statSync(abs);
|
|
1309
|
+
isDir = target.isDirectory();
|
|
1310
|
+
isFile = target.isFile();
|
|
1311
|
+
} catch {
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (isDir) {
|
|
1316
|
+
let real;
|
|
1317
|
+
try {
|
|
1318
|
+
real = realpathSync(abs);
|
|
1319
|
+
} catch {
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
if (seen.has(real)) continue;
|
|
1323
|
+
seen.add(real);
|
|
1324
|
+
walk(root, abs, out, seen);
|
|
1325
|
+
} else if (isFile) {
|
|
687
1326
|
out.push(relative(root, abs).split(sep).join("/"));
|
|
688
1327
|
}
|
|
689
1328
|
}
|
|
@@ -712,9 +1351,16 @@ function filterGitIgnored(root, files) {
|
|
|
712
1351
|
return files;
|
|
713
1352
|
}
|
|
714
1353
|
}
|
|
1354
|
+
function safeRealpath(p) {
|
|
1355
|
+
try {
|
|
1356
|
+
return realpathSync(p);
|
|
1357
|
+
} catch {
|
|
1358
|
+
return p;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
715
1361
|
function wholeRepoFiles(root) {
|
|
716
1362
|
const out = [];
|
|
717
|
-
walk(root, root, out);
|
|
1363
|
+
walk(root, root, out, /* @__PURE__ */ new Set([safeRealpath(root)]));
|
|
718
1364
|
return isGitRepo(root) ? filterGitIgnored(root, out) : out;
|
|
719
1365
|
}
|
|
720
1366
|
function selectFiles(root, all) {
|
|
@@ -738,7 +1384,7 @@ function formatReport(findings, meta) {
|
|
|
738
1384
|
const loc = `${f.file}:${f.line}`;
|
|
739
1385
|
const firstOccurrence = !seenRule.has(f.ruleId);
|
|
740
1386
|
seenRule.add(f.ruleId);
|
|
741
|
-
const hint = firstOccurrence ? ` (see
|
|
1387
|
+
const hint = firstOccurrence ? ` (see rule ${f.ruleId} in your installed jig skill's rules/)` : "";
|
|
742
1388
|
return { symbol, ruleId: f.ruleId, message: f.message, loc, fileOnly: f.file, bucket: f.bucket, hint };
|
|
743
1389
|
});
|
|
744
1390
|
if (rows.length > 0) {
|
|
@@ -768,9 +1414,7 @@ function formatReport(findings, meta) {
|
|
|
768
1414
|
lines.push("");
|
|
769
1415
|
if (meta.noTokenLayer) {
|
|
770
1416
|
lines.push(" No file references a Jig token, so H-47 (hard-coded values) was not run.");
|
|
771
|
-
lines.push("
|
|
772
|
-
lines.push(' @import ".jig/tokens/brand.default.css";');
|
|
773
|
-
lines.push(' @import ".jig/tokens/mode.product.css";');
|
|
1417
|
+
lines.push(" Run 'jig init' to generate a brand file and wire it (with a mode file) into your CSS.");
|
|
774
1418
|
lines.push("");
|
|
775
1419
|
}
|
|
776
1420
|
}
|
|
@@ -781,16 +1425,28 @@ function formatReport(findings, meta) {
|
|
|
781
1425
|
if (warnings > 0) summaryParts.push(plural(warnings, "warning"));
|
|
782
1426
|
if (notes > 0) summaryParts.push(plural(notes, "note"));
|
|
783
1427
|
const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
|
|
784
|
-
|
|
1428
|
+
const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
|
|
1429
|
+
lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}, ${rulesFired} fired`);
|
|
1430
|
+
if (meta.unscanned && meta.unscanned.count > 0) {
|
|
1431
|
+
const exts = meta.unscanned.extensions.join(", ");
|
|
1432
|
+
lines.push("");
|
|
1433
|
+
lines.push(
|
|
1434
|
+
` ${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.`
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
785
1437
|
const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
|
|
786
1438
|
const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
|
|
787
|
-
lines.push(
|
|
1439
|
+
lines.push(
|
|
1440
|
+
` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run`
|
|
1441
|
+
);
|
|
788
1442
|
return lines.join("\n");
|
|
789
1443
|
}
|
|
790
1444
|
|
|
791
1445
|
// src/check/token-layer.ts
|
|
792
1446
|
var IMPORT_RE = /@import\s+(?:url\()?["']?[^"')]*\.jig\/tokens\/[^"')]+\.css["']?\)?/i;
|
|
793
1447
|
var VAR_RE = /var\(\s*--([\w-]+)/g;
|
|
1448
|
+
var THEME_DECL_RE = /@theme\b[^{]*\{([\s\S]*?)\}/gi;
|
|
1449
|
+
var CUSTOM_PROP_RE = /--([\w-]+)\s*:/g;
|
|
794
1450
|
function participatesInTokenLayer(source, tokens) {
|
|
795
1451
|
if (IMPORT_RE.test(source)) return true;
|
|
796
1452
|
VAR_RE.lastIndex = 0;
|
|
@@ -798,12 +1454,91 @@ function participatesInTokenLayer(source, tokens) {
|
|
|
798
1454
|
while (m = VAR_RE.exec(source)) {
|
|
799
1455
|
if (Object.prototype.hasOwnProperty.call(tokens, m[1])) return true;
|
|
800
1456
|
}
|
|
1457
|
+
THEME_DECL_RE.lastIndex = 0;
|
|
1458
|
+
let theme;
|
|
1459
|
+
while (theme = THEME_DECL_RE.exec(source)) {
|
|
1460
|
+
if (IMPORT_RE.test(theme[1])) return true;
|
|
1461
|
+
CUSTOM_PROP_RE.lastIndex = 0;
|
|
1462
|
+
let prop;
|
|
1463
|
+
while (prop = CUSTOM_PROP_RE.exec(theme[1])) {
|
|
1464
|
+
if (Object.prototype.hasOwnProperty.call(tokens, prop[1])) return true;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
801
1467
|
return false;
|
|
802
1468
|
}
|
|
803
1469
|
|
|
1470
|
+
// src/check/ext.ts
|
|
1471
|
+
var CSS_EXTENSIONS = [".css", ".scss", ".less"];
|
|
1472
|
+
function hasExtension(file, exts) {
|
|
1473
|
+
const lower = file.toLowerCase();
|
|
1474
|
+
return exts.some((e) => lower.endsWith(e));
|
|
1475
|
+
}
|
|
1476
|
+
var STYLE_HOST_EXTENSIONS = [
|
|
1477
|
+
".html",
|
|
1478
|
+
".htm",
|
|
1479
|
+
".vue",
|
|
1480
|
+
".svelte",
|
|
1481
|
+
".astro",
|
|
1482
|
+
".jsx",
|
|
1483
|
+
".tsx",
|
|
1484
|
+
".js",
|
|
1485
|
+
".ts",
|
|
1486
|
+
".mjs",
|
|
1487
|
+
".cjs",
|
|
1488
|
+
".mts",
|
|
1489
|
+
".cts",
|
|
1490
|
+
".php",
|
|
1491
|
+
".erb",
|
|
1492
|
+
".twig",
|
|
1493
|
+
".hbs",
|
|
1494
|
+
".mdx",
|
|
1495
|
+
// Server-rendered markup. Every one of these is HTML with a template syntax
|
|
1496
|
+
// threaded through it, so its `<style>` blocks and `style` attributes are the
|
|
1497
|
+
// same constructs already handled — the template tags sit outside the style
|
|
1498
|
+
// regions and are blanked with the rest of the markup.
|
|
1499
|
+
".asp",
|
|
1500
|
+
".aspx",
|
|
1501
|
+
".ascx",
|
|
1502
|
+
".master",
|
|
1503
|
+
// Classic ASP, ASP.NET
|
|
1504
|
+
".cshtml",
|
|
1505
|
+
".vbhtml",
|
|
1506
|
+
".razor",
|
|
1507
|
+
// Razor
|
|
1508
|
+
".jsp",
|
|
1509
|
+
".jspx",
|
|
1510
|
+
// Java
|
|
1511
|
+
".eex",
|
|
1512
|
+
".heex",
|
|
1513
|
+
".leex",
|
|
1514
|
+
// Phoenix
|
|
1515
|
+
".ejs",
|
|
1516
|
+
".njk",
|
|
1517
|
+
".liquid",
|
|
1518
|
+
".mustache",
|
|
1519
|
+
// Node/Jinja-family engines
|
|
1520
|
+
".vm",
|
|
1521
|
+
".ftl",
|
|
1522
|
+
// Velocity, FreeMarker
|
|
1523
|
+
".jinja",
|
|
1524
|
+
".jinja2",
|
|
1525
|
+
".j2",
|
|
1526
|
+
// Jinja
|
|
1527
|
+
// Indentation-delimited templates. These carry no HTML-shaped constructs at
|
|
1528
|
+
// all — their style regions are found by their own block markers and
|
|
1529
|
+
// attribute syntax instead. See `INDENTED_SYNTAXES` in styles.ts.
|
|
1530
|
+
".pug",
|
|
1531
|
+
".jade",
|
|
1532
|
+
".haml",
|
|
1533
|
+
".slim"
|
|
1534
|
+
];
|
|
1535
|
+
function isStyleBearing(file) {
|
|
1536
|
+
return hasExtension(file, CSS_EXTENSIONS) || hasExtension(file, STYLE_HOST_EXTENSIONS);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
804
1539
|
// src/check/run.ts
|
|
805
|
-
import { readFileSync as
|
|
806
|
-
import { join as
|
|
1540
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
1541
|
+
import { join as join15 } from "path";
|
|
807
1542
|
|
|
808
1543
|
// src/check/css.ts
|
|
809
1544
|
function maskComments(source) {
|
|
@@ -852,7 +1587,12 @@ function splitRuleBlocks(source) {
|
|
|
852
1587
|
const ch = source[i];
|
|
853
1588
|
if (ch === "\n") line++;
|
|
854
1589
|
if (ch === "{") {
|
|
855
|
-
stack.push({
|
|
1590
|
+
stack.push({
|
|
1591
|
+
selectorStart: lastBoundary,
|
|
1592
|
+
bodyStart: i + 1,
|
|
1593
|
+
bodyStartLine: line,
|
|
1594
|
+
isAtRule: cleanSelector(source.slice(lastBoundary, i)).startsWith("@")
|
|
1595
|
+
});
|
|
856
1596
|
lastBoundary = i + 1;
|
|
857
1597
|
} else if (ch === "}") {
|
|
858
1598
|
const top = stack.pop();
|
|
@@ -860,7 +1600,8 @@ function splitRuleBlocks(source) {
|
|
|
860
1600
|
blocks.push({
|
|
861
1601
|
selector: cleanSelector(source.slice(top.selectorStart, top.bodyStart - 1)),
|
|
862
1602
|
body: source.slice(top.bodyStart, i),
|
|
863
|
-
bodyStartLine: top.bodyStartLine
|
|
1603
|
+
bodyStartLine: top.bodyStartLine,
|
|
1604
|
+
atRuleDepth: stack.filter((f) => f.isAtRule).length
|
|
864
1605
|
});
|
|
865
1606
|
}
|
|
866
1607
|
lastBoundary = i + 1;
|
|
@@ -870,7 +1611,7 @@ function splitRuleBlocks(source) {
|
|
|
870
1611
|
}
|
|
871
1612
|
function blankNested(body) {
|
|
872
1613
|
const out = body.split("");
|
|
873
|
-
const
|
|
1614
|
+
const blank2 = (from, to) => {
|
|
874
1615
|
for (let j = from; j < to; j++) if (out[j] !== "\n") out[j] = " ";
|
|
875
1616
|
};
|
|
876
1617
|
let depth = 0;
|
|
@@ -878,7 +1619,7 @@ function blankNested(body) {
|
|
|
878
1619
|
for (let i = 0; i < body.length; i++) {
|
|
879
1620
|
const ch = body[i];
|
|
880
1621
|
if (ch === "{") {
|
|
881
|
-
if (depth === 0)
|
|
1622
|
+
if (depth === 0) blank2(segmentStart, i);
|
|
882
1623
|
depth++;
|
|
883
1624
|
out[i] = " ";
|
|
884
1625
|
} else if (ch === "}") {
|
|
@@ -929,11 +1670,221 @@ function sourceLine(source, line) {
|
|
|
929
1670
|
return (lines[line - 1] ?? "").trim();
|
|
930
1671
|
}
|
|
931
1672
|
|
|
932
|
-
// src/check/
|
|
933
|
-
|
|
934
|
-
|
|
1673
|
+
// src/check/styles.ts
|
|
1674
|
+
function isStyleHost(file) {
|
|
1675
|
+
return hasExtension(file, STYLE_HOST_EXTENSIONS);
|
|
1676
|
+
}
|
|
1677
|
+
function isMarkupHost(file) {
|
|
1678
|
+
return hasExtension(file, MARKUP_HOSTS);
|
|
1679
|
+
}
|
|
1680
|
+
var MARKUP_HOSTS = [
|
|
1681
|
+
".html",
|
|
1682
|
+
".htm",
|
|
1683
|
+
".vue",
|
|
1684
|
+
".svelte",
|
|
1685
|
+
".astro",
|
|
1686
|
+
".php",
|
|
1687
|
+
".erb",
|
|
1688
|
+
".twig",
|
|
1689
|
+
".hbs",
|
|
1690
|
+
".mdx",
|
|
1691
|
+
".jsx",
|
|
1692
|
+
".tsx",
|
|
1693
|
+
".asp",
|
|
1694
|
+
".aspx",
|
|
1695
|
+
".ascx",
|
|
1696
|
+
".master",
|
|
1697
|
+
".cshtml",
|
|
1698
|
+
".vbhtml",
|
|
1699
|
+
".razor",
|
|
1700
|
+
".jsp",
|
|
1701
|
+
".jspx",
|
|
1702
|
+
".eex",
|
|
1703
|
+
".heex",
|
|
1704
|
+
".leex",
|
|
1705
|
+
".ejs",
|
|
1706
|
+
".njk",
|
|
1707
|
+
".liquid",
|
|
1708
|
+
".mustache",
|
|
1709
|
+
".vm",
|
|
1710
|
+
".ftl",
|
|
1711
|
+
".jinja",
|
|
1712
|
+
".jinja2",
|
|
1713
|
+
".j2"
|
|
1714
|
+
];
|
|
1715
|
+
var INDENTED_SYNTAXES = {
|
|
1716
|
+
".pug": { blockMarker: /^(\s*)style\.?\s*$/, attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g },
|
|
1717
|
+
".jade": { blockMarker: /^(\s*)style\.?\s*$/, attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g },
|
|
1718
|
+
".haml": {
|
|
1719
|
+
blockMarker: /^(\s*)(?::css|%style)\s*$/,
|
|
1720
|
+
attr: /(?::style\s*=>|\bstyle\s*:)\s*(["'])([\s\S]*?)\1/g
|
|
1721
|
+
},
|
|
1722
|
+
".slim": {
|
|
1723
|
+
blockMarker: /^(\s*)(?:css:|style:)\s*$/,
|
|
1724
|
+
attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
function indentedSyntaxFor(file) {
|
|
935
1728
|
const lower = file.toLowerCase();
|
|
936
|
-
|
|
1729
|
+
const ext = Object.keys(INDENTED_SYNTAXES).find((e) => lower.endsWith(e));
|
|
1730
|
+
return ext ? INDENTED_SYNTAXES[ext] : void 0;
|
|
1731
|
+
}
|
|
1732
|
+
function indentedSpans(source, syntax) {
|
|
1733
|
+
const spans = [];
|
|
1734
|
+
const lines = source.split("\n");
|
|
1735
|
+
const offsets = [];
|
|
1736
|
+
let at = 0;
|
|
1737
|
+
for (const line of lines) {
|
|
1738
|
+
offsets.push(at);
|
|
1739
|
+
at += line.length + 1;
|
|
1740
|
+
}
|
|
1741
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1742
|
+
const marker = syntax.blockMarker.exec(lines[i]);
|
|
1743
|
+
if (!marker) continue;
|
|
1744
|
+
const markerIndent = marker[1].length;
|
|
1745
|
+
let end = i;
|
|
1746
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1747
|
+
if (lines[j].trim() === "") {
|
|
1748
|
+
end = j;
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
const indent = lines[j].length - lines[j].trimStart().length;
|
|
1752
|
+
if (indent <= markerIndent) break;
|
|
1753
|
+
end = j;
|
|
1754
|
+
}
|
|
1755
|
+
if (end === i) continue;
|
|
1756
|
+
const start = offsets[i + 1];
|
|
1757
|
+
const last = offsets[end] + lines[end].length;
|
|
1758
|
+
spans.push({ start, end: last });
|
|
1759
|
+
i = end;
|
|
1760
|
+
}
|
|
1761
|
+
return spans;
|
|
1762
|
+
}
|
|
1763
|
+
var STYLE_BLOCK = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
|
|
1764
|
+
var STYLE_ATTR = /\bstyle\s*=\s*(["'])([\s\S]*?)\1/gi;
|
|
1765
|
+
var CSS_TAG = /\b(?:styled(?:\.\w+|\([^)]*\))(?:\s*\.\w+\([^)]*\))*|css|createGlobalStyle|keyframes|injectGlobal)\s*`/g;
|
|
1766
|
+
function readTemplate(source, openBacktick) {
|
|
1767
|
+
let i = openBacktick + 1;
|
|
1768
|
+
const start = i;
|
|
1769
|
+
while (i < source.length) {
|
|
1770
|
+
const ch = source[i];
|
|
1771
|
+
if (ch === "\\") {
|
|
1772
|
+
i += 2;
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
if (ch === "`") return { start, end: i };
|
|
1776
|
+
if (ch === "$" && source[i + 1] === "{") {
|
|
1777
|
+
let depth = 1;
|
|
1778
|
+
i += 2;
|
|
1779
|
+
while (i < source.length && depth > 0) {
|
|
1780
|
+
if (source[i] === "\\") {
|
|
1781
|
+
i += 2;
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
if (source[i] === "{") depth++;
|
|
1785
|
+
else if (source[i] === "}") depth--;
|
|
1786
|
+
else if (source[i] === "`") {
|
|
1787
|
+
const inner = readTemplate(source, i);
|
|
1788
|
+
if (!inner) return null;
|
|
1789
|
+
i = inner.end;
|
|
1790
|
+
}
|
|
1791
|
+
i++;
|
|
1792
|
+
}
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
i++;
|
|
1796
|
+
}
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1799
|
+
var STYLE_OBJECT = /\bstyle\s*=\s*\{\{/g;
|
|
1800
|
+
function readObject(source, open) {
|
|
1801
|
+
let depth = 0;
|
|
1802
|
+
let i = open + 1;
|
|
1803
|
+
const start = open + 2;
|
|
1804
|
+
while (i < source.length) {
|
|
1805
|
+
const ch = source[i];
|
|
1806
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
1807
|
+
const quote = ch;
|
|
1808
|
+
i++;
|
|
1809
|
+
while (i < source.length && source[i] !== quote) {
|
|
1810
|
+
if (source[i] === "\\") i++;
|
|
1811
|
+
i++;
|
|
1812
|
+
}
|
|
1813
|
+
i++;
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
if (ch === "{") depth++;
|
|
1817
|
+
else if (ch === "}") {
|
|
1818
|
+
depth--;
|
|
1819
|
+
if (depth === 0) return { start, end: i };
|
|
1820
|
+
}
|
|
1821
|
+
i++;
|
|
1822
|
+
}
|
|
1823
|
+
return null;
|
|
1824
|
+
}
|
|
1825
|
+
function styleObjectToCss(body) {
|
|
1826
|
+
let out = "";
|
|
1827
|
+
for (const ch of body) {
|
|
1828
|
+
if (ch === '"' || ch === "'" || ch === "`") out += " ";
|
|
1829
|
+
else if (ch === ",") out += ";";
|
|
1830
|
+
else out += ch;
|
|
1831
|
+
}
|
|
1832
|
+
return out;
|
|
1833
|
+
}
|
|
1834
|
+
function maskNonStyleRegions(source, file) {
|
|
1835
|
+
if (hasExtension(file, CSS_EXTENSIONS)) return source;
|
|
1836
|
+
if (!isStyleHost(file)) return blank(source);
|
|
1837
|
+
const spans = [];
|
|
1838
|
+
const indented = indentedSyntaxFor(file);
|
|
1839
|
+
if (indented) {
|
|
1840
|
+
spans.push(...indentedSpans(source, indented));
|
|
1841
|
+
for (const m of source.matchAll(indented.attr)) {
|
|
1842
|
+
const valueStart = m.index + m[0].indexOf(m[1]) + 1;
|
|
1843
|
+
spans.push({ start: valueStart, end: valueStart + m[2].length, braced: true });
|
|
1844
|
+
}
|
|
1845
|
+
return applySpans(source, spans);
|
|
1846
|
+
}
|
|
1847
|
+
if (hasExtension(file, MARKUP_HOSTS)) {
|
|
1848
|
+
for (const m of source.matchAll(STYLE_BLOCK)) {
|
|
1849
|
+
const bodyStart = m.index + m[0].indexOf(">") + 1;
|
|
1850
|
+
spans.push({ start: bodyStart, end: bodyStart + m[1].length });
|
|
1851
|
+
}
|
|
1852
|
+
for (const m of source.matchAll(STYLE_ATTR)) {
|
|
1853
|
+
const valueStart = m.index + m[0].indexOf(m[1]) + 1;
|
|
1854
|
+
spans.push({ start: valueStart, end: valueStart + m[2].length, braced: true });
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
for (const m of source.matchAll(CSS_TAG)) {
|
|
1858
|
+
const backtick = m.index + m[0].length - 1;
|
|
1859
|
+
const span = readTemplate(source, backtick);
|
|
1860
|
+
if (span) spans.push({ ...span, braced: true });
|
|
1861
|
+
}
|
|
1862
|
+
for (const m of source.matchAll(STYLE_OBJECT)) {
|
|
1863
|
+
const span = readObject(source, m.index + m[0].length - 2);
|
|
1864
|
+
if (span) spans.push({ ...span, transform: styleObjectToCss, braced: true });
|
|
1865
|
+
}
|
|
1866
|
+
return applySpans(source, spans);
|
|
1867
|
+
}
|
|
1868
|
+
function applySpans(source, spans) {
|
|
1869
|
+
if (spans.length === 0) return blank(source);
|
|
1870
|
+
const out = blank(source).split("");
|
|
1871
|
+
for (const span of spans) {
|
|
1872
|
+
const text = source.slice(span.start, span.end);
|
|
1873
|
+
const rendered = span.transform ? span.transform(text) : text;
|
|
1874
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
1875
|
+
out[span.start + i] = rendered[i];
|
|
1876
|
+
}
|
|
1877
|
+
if (span.braced && span.start > 0 && span.end < out.length) {
|
|
1878
|
+
out[span.start - 1] = "{";
|
|
1879
|
+
out[span.end] = "}";
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
return out.join("");
|
|
1883
|
+
}
|
|
1884
|
+
function blank(source) {
|
|
1885
|
+
let out = "";
|
|
1886
|
+
for (const ch of source) out += ch === "\n" ? "\n" : " ";
|
|
1887
|
+
return out;
|
|
937
1888
|
}
|
|
938
1889
|
|
|
939
1890
|
// src/check/finding.ts
|
|
@@ -955,7 +1906,7 @@ var CLIP_RE = /(?:-webkit-)?background-clip\s*:\s*text\b/i;
|
|
|
955
1906
|
var GRADIENT_RE = /(?:background|background-image)\s*:[^;]*(?:linear|radial|conic)-gradient\s*\(/i;
|
|
956
1907
|
var gradientText = {
|
|
957
1908
|
name: "gradient-text",
|
|
958
|
-
appliesTo: (file) =>
|
|
1909
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
959
1910
|
run(source, file, ctx) {
|
|
960
1911
|
const findings = [];
|
|
961
1912
|
for (const block of leafBlocks(source)) {
|
|
@@ -1184,7 +2135,7 @@ function hasTranslucentBackground(body, tokens) {
|
|
|
1184
2135
|
}
|
|
1185
2136
|
var backdropBlur = {
|
|
1186
2137
|
name: "backdrop-blur",
|
|
1187
|
-
appliesTo: (file) =>
|
|
2138
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1188
2139
|
run(source, file, ctx) {
|
|
1189
2140
|
const findings = [];
|
|
1190
2141
|
for (const block of leafBlocks(source)) {
|
|
@@ -1215,7 +2166,7 @@ function isPure(rgb, target) {
|
|
|
1215
2166
|
}
|
|
1216
2167
|
var pureBlackWhite = {
|
|
1217
2168
|
name: "pure-black-white",
|
|
1218
|
-
appliesTo: (file) =>
|
|
2169
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1219
2170
|
run(source, file, ctx) {
|
|
1220
2171
|
const findings = [];
|
|
1221
2172
|
for (const block of leafBlocks(source)) {
|
|
@@ -1250,6 +2201,104 @@ var pureBlackWhite = {
|
|
|
1250
2201
|
}
|
|
1251
2202
|
};
|
|
1252
2203
|
|
|
2204
|
+
// src/check/tailwind.ts
|
|
2205
|
+
var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
|
|
2206
|
+
function readBraced(source, open) {
|
|
2207
|
+
let depth = 0;
|
|
2208
|
+
for (let i = open; i < source.length; i++) {
|
|
2209
|
+
const ch = source[i];
|
|
2210
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
2211
|
+
const quote = ch;
|
|
2212
|
+
i++;
|
|
2213
|
+
while (i < source.length && source[i] !== quote) {
|
|
2214
|
+
if (source[i] === "\\") i++;
|
|
2215
|
+
i++;
|
|
2216
|
+
}
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
if (ch === "{") depth++;
|
|
2220
|
+
else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
|
|
2221
|
+
}
|
|
2222
|
+
return null;
|
|
2223
|
+
}
|
|
2224
|
+
function classAttributeValues(source) {
|
|
2225
|
+
const out = [];
|
|
2226
|
+
const lineAt = (offset) => source.slice(0, offset).split("\n").length;
|
|
2227
|
+
for (const m of source.matchAll(CLASS_ATTR)) {
|
|
2228
|
+
const at = m.index + m[0].length;
|
|
2229
|
+
const opener = source[at];
|
|
2230
|
+
if (opener === '"' || opener === "'") {
|
|
2231
|
+
const end = source.indexOf(opener, at + 1);
|
|
2232
|
+
if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
|
|
2233
|
+
continue;
|
|
2234
|
+
}
|
|
2235
|
+
if (opener !== "{") continue;
|
|
2236
|
+
const raw = readBraced(source, at);
|
|
2237
|
+
if (raw === null) continue;
|
|
2238
|
+
const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
|
|
2239
|
+
(q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
|
|
2240
|
+
);
|
|
2241
|
+
if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
|
|
2242
|
+
}
|
|
2243
|
+
return out;
|
|
2244
|
+
}
|
|
2245
|
+
var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
|
|
2246
|
+
var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
|
|
2247
|
+
var LENGTH = /^-?\d*\.?\d+(?:px|rem|em|pt|vh|vw|vmin|vmax|ch|ex)$/i;
|
|
2248
|
+
function arbitraryValues(classes) {
|
|
2249
|
+
const out = [];
|
|
2250
|
+
for (const m of classes.matchAll(ARBITRARY)) {
|
|
2251
|
+
const [, utility, rawValue] = m;
|
|
2252
|
+
const value = rawValue.replace(/_/g, " ");
|
|
2253
|
+
if (/var\(|--/.test(value)) continue;
|
|
2254
|
+
if (COLOUR.test(value)) out.push({ utility, value, kind: "colour" });
|
|
2255
|
+
else if (LENGTH.test(value)) out.push({ utility, value, kind: "length" });
|
|
2256
|
+
}
|
|
2257
|
+
return out;
|
|
2258
|
+
}
|
|
2259
|
+
var PALETTE = {
|
|
2260
|
+
white: "#ffffff",
|
|
2261
|
+
black: "#000000",
|
|
2262
|
+
"gray-50": "#f9fafb",
|
|
2263
|
+
"gray-100": "#f3f4f6",
|
|
2264
|
+
"gray-200": "#e5e7eb",
|
|
2265
|
+
"gray-300": "#d1d5db",
|
|
2266
|
+
"gray-400": "#9ca3af",
|
|
2267
|
+
"gray-500": "#6b7280",
|
|
2268
|
+
"gray-600": "#4b5563",
|
|
2269
|
+
"gray-700": "#374151",
|
|
2270
|
+
"gray-800": "#1f2937",
|
|
2271
|
+
"gray-900": "#111827",
|
|
2272
|
+
"gray-950": "#030712",
|
|
2273
|
+
"slate-50": "#f8fafc",
|
|
2274
|
+
"slate-400": "#94a3b8",
|
|
2275
|
+
"slate-500": "#64748b",
|
|
2276
|
+
"slate-800": "#1e293b",
|
|
2277
|
+
"slate-900": "#0f172a",
|
|
2278
|
+
"slate-950": "#020617",
|
|
2279
|
+
"zinc-400": "#a1a1aa",
|
|
2280
|
+
"zinc-500": "#71717a",
|
|
2281
|
+
"zinc-900": "#18181b",
|
|
2282
|
+
"neutral-400": "#a3a3a3",
|
|
2283
|
+
"neutral-500": "#737373",
|
|
2284
|
+
"neutral-900": "#171717",
|
|
2285
|
+
"stone-400": "#a8a29e",
|
|
2286
|
+
"stone-500": "#78716c",
|
|
2287
|
+
"stone-900": "#1c1917"
|
|
2288
|
+
};
|
|
2289
|
+
function palettePairs(classes) {
|
|
2290
|
+
let background;
|
|
2291
|
+
let foreground;
|
|
2292
|
+
for (const token of classes.split(/\s+/)) {
|
|
2293
|
+
const bare = token.replace(/^(?:[\w-]+:)*/, "");
|
|
2294
|
+
const bg = /^bg-(.+)$/.exec(bare);
|
|
2295
|
+
const fg = /^text-(.+)$/.exec(bare);
|
|
2296
|
+
if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
|
|
2297
|
+
if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
|
|
2298
|
+
}
|
|
2299
|
+
return background && foreground ? [{ background, foreground }] : [];
|
|
2300
|
+
}
|
|
2301
|
+
|
|
1253
2302
|
// src/check/detectors/contrast-floor.ts
|
|
1254
2303
|
var FLOOR = 4.5;
|
|
1255
2304
|
var LARGE_FLOOR = 3;
|
|
@@ -1274,7 +2323,7 @@ function resolveFloor(body) {
|
|
|
1274
2323
|
var DECL_RE = /(?<![-\w])(color|background(?:-color)?)\s*:\s*([^;]+)(?:;|$)/gi;
|
|
1275
2324
|
var contrastFloor = {
|
|
1276
2325
|
name: "contrast-floor",
|
|
1277
|
-
appliesTo: (file) =>
|
|
2326
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1278
2327
|
run(source, file, ctx) {
|
|
1279
2328
|
const findings = [];
|
|
1280
2329
|
for (const block of leafBlocks(source)) {
|
|
@@ -1311,6 +2360,25 @@ var contrastFloor = {
|
|
|
1311
2360
|
)
|
|
1312
2361
|
);
|
|
1313
2362
|
}
|
|
2363
|
+
for (const attr of isMarkupHost(file) ? classAttributeValues(ctx.raw) : []) {
|
|
2364
|
+
for (const pair of palettePairs(attr.classes)) {
|
|
2365
|
+
const fg = resolveOpaqueColor(pair.foreground, ctx.tokens);
|
|
2366
|
+
const bg = resolveOpaqueColor(pair.background, ctx.tokens);
|
|
2367
|
+
if (!fg || !bg) continue;
|
|
2368
|
+
const ratio = contrastRatio(fg, bg);
|
|
2369
|
+
if (ratio >= 4.5) continue;
|
|
2370
|
+
findings.push(
|
|
2371
|
+
mkFinding(
|
|
2372
|
+
ctx,
|
|
2373
|
+
"contrast-floor",
|
|
2374
|
+
file,
|
|
2375
|
+
attr.line,
|
|
2376
|
+
`Foreground/background contrast is ${ratio.toFixed(2)}:1, below the 4.5:1 floor (${pair.foreground} on ${pair.background})`,
|
|
2377
|
+
sourceLine(ctx.raw, attr.line)
|
|
2378
|
+
)
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
1314
2382
|
return findings;
|
|
1315
2383
|
}
|
|
1316
2384
|
};
|
|
@@ -1320,7 +2388,7 @@ var OUTLINE_NONE_RE = /(?<![-\w])outline\s*:\s*(none|0(?:px)?)\s*(?:!important)?
|
|
|
1320
2388
|
var FOCUS_VISIBLE_RE = /:focus-visible\b/i;
|
|
1321
2389
|
var focusRemoved = {
|
|
1322
2390
|
name: "focus-removed",
|
|
1323
|
-
appliesTo: (file) =>
|
|
2391
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1324
2392
|
run(source, file, ctx) {
|
|
1325
2393
|
if (FOCUS_VISIBLE_RE.test(source)) return [];
|
|
1326
2394
|
const findings = [];
|
|
@@ -1381,9 +2449,10 @@ var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
|
|
|
1381
2449
|
var KEYFRAME_STEP_RE = /^(from|to|\d+(\.\d+)?%)$/i;
|
|
1382
2450
|
var hardcodedValue = {
|
|
1383
2451
|
name: "hardcoded-value",
|
|
1384
|
-
appliesTo: (file) =>
|
|
2452
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1385
2453
|
run(source, file, ctx) {
|
|
1386
|
-
|
|
2454
|
+
const participates = isStyleHost(file) ? ctx.projectParticipates : participatesInTokenLayer(source, ctx.tokens);
|
|
2455
|
+
if (!participates) return [];
|
|
1387
2456
|
const findings = [];
|
|
1388
2457
|
for (const block of leafBlocks(source)) {
|
|
1389
2458
|
if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
|
|
@@ -1433,6 +2502,20 @@ var hardcodedValue = {
|
|
|
1433
2502
|
}
|
|
1434
2503
|
}
|
|
1435
2504
|
}
|
|
2505
|
+
for (const attr of isMarkupHost(file) ? classAttributeValues(ctx.raw) : []) {
|
|
2506
|
+
for (const found of arbitraryValues(attr.classes)) {
|
|
2507
|
+
findings.push(
|
|
2508
|
+
mkFinding(
|
|
2509
|
+
ctx,
|
|
2510
|
+
"hardcoded-value",
|
|
2511
|
+
file,
|
|
2512
|
+
attr.line,
|
|
2513
|
+
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])`,
|
|
2514
|
+
attr.classes.length > 80 ? `${attr.classes.slice(0, 77)}\u2026` : attr.classes
|
|
2515
|
+
)
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
1436
2519
|
return findings;
|
|
1437
2520
|
}
|
|
1438
2521
|
};
|
|
@@ -1446,7 +2529,7 @@ var MIN_LIGHTNESS = 8;
|
|
|
1446
2529
|
var MAX_LIGHTNESS = 92;
|
|
1447
2530
|
var violetBandHue = {
|
|
1448
2531
|
name: "violet-band-hue",
|
|
1449
|
-
appliesTo: (file) =>
|
|
2532
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1450
2533
|
run(source, file, ctx) {
|
|
1451
2534
|
const findings = [];
|
|
1452
2535
|
const starts = buildLineIndex(source);
|
|
@@ -1489,7 +2572,7 @@ function getDetector(name) {
|
|
|
1489
2572
|
}
|
|
1490
2573
|
|
|
1491
2574
|
// src/check/run.ts
|
|
1492
|
-
function runChecks(projectRoot, files, index, tokens, bucketFilter) {
|
|
2575
|
+
function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false) {
|
|
1493
2576
|
const findings = [];
|
|
1494
2577
|
const active = index.filter(
|
|
1495
2578
|
(e) => !!e.detector && !!getDetector(e.detector)
|
|
@@ -1501,13 +2584,13 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter) {
|
|
|
1501
2584
|
if (applicable.length === 0) continue;
|
|
1502
2585
|
let raw;
|
|
1503
2586
|
try {
|
|
1504
|
-
raw =
|
|
2587
|
+
raw = readFileSync10(join15(projectRoot, file), "utf8");
|
|
1505
2588
|
} catch {
|
|
1506
2589
|
continue;
|
|
1507
2590
|
}
|
|
1508
|
-
const source = maskComments(raw);
|
|
2591
|
+
const source = maskComments(maskNonStyleRegions(raw, file));
|
|
1509
2592
|
for (const { entry, detector } of applicable) {
|
|
1510
|
-
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens };
|
|
2593
|
+
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw };
|
|
1511
2594
|
findings.push(...detector.run(source, file, ctx));
|
|
1512
2595
|
}
|
|
1513
2596
|
}
|
|
@@ -1515,40 +2598,101 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter) {
|
|
|
1515
2598
|
}
|
|
1516
2599
|
|
|
1517
2600
|
// src/check/tokens.ts
|
|
1518
|
-
import { existsSync as
|
|
1519
|
-
import { join as
|
|
1520
|
-
function
|
|
1521
|
-
const
|
|
1522
|
-
const
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
2601
|
+
import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync11 } from "fs";
|
|
2602
|
+
import { join as join16 } from "path";
|
|
2603
|
+
function rootDeclarations(source) {
|
|
2604
|
+
const out = {};
|
|
2605
|
+
for (const block of splitRuleBlocks(source)) {
|
|
2606
|
+
if (block.atRuleDepth !== 0) continue;
|
|
2607
|
+
if (block.body.includes("{")) continue;
|
|
2608
|
+
if (block.selector.trim() !== ":root") continue;
|
|
2609
|
+
const re = /--([\w-]+)\s*:\s*([^;]+);/g;
|
|
2610
|
+
let m;
|
|
2611
|
+
while (m = re.exec(block.body)) out[m[1]] = m[2].trim();
|
|
2612
|
+
}
|
|
2613
|
+
return out;
|
|
2614
|
+
}
|
|
2615
|
+
function loadTokenMap(projectRoot, consumerFiles = []) {
|
|
2616
|
+
const jig = {};
|
|
2617
|
+
const tokensDir = join16(projectRoot, ".jig", "tokens");
|
|
2618
|
+
if (existsSync9(tokensDir)) {
|
|
2619
|
+
for (const file of readdirSync7(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
2620
|
+
Object.assign(jig, rootDeclarations(readFileSync11(join16(tokensDir, file), "utf8")));
|
|
1535
2621
|
}
|
|
1536
2622
|
}
|
|
2623
|
+
const consumer = {};
|
|
2624
|
+
const ambiguous = /* @__PURE__ */ new Set();
|
|
2625
|
+
const vendored = join16(".jig", "tokens");
|
|
2626
|
+
for (const file of consumerFiles) {
|
|
2627
|
+
if (file.includes(vendored)) continue;
|
|
2628
|
+
let source;
|
|
2629
|
+
try {
|
|
2630
|
+
source = readFileSync11(join16(projectRoot, file), "utf8");
|
|
2631
|
+
} catch {
|
|
2632
|
+
continue;
|
|
2633
|
+
}
|
|
2634
|
+
for (const [name, value] of Object.entries(rootDeclarations(maskNonStyleRegions(source, file)))) {
|
|
2635
|
+
if (name in consumer && consumer[name] !== value) ambiguous.add(name);
|
|
2636
|
+
consumer[name] = value;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
const tokens = { ...consumer };
|
|
2640
|
+
for (const [name, value] of Object.entries(jig)) {
|
|
2641
|
+
if (name in consumer && consumer[name] !== value) ambiguous.add(name);
|
|
2642
|
+
tokens[name] = value;
|
|
2643
|
+
}
|
|
2644
|
+
for (const name of ambiguous) delete tokens[name];
|
|
1537
2645
|
return tokens;
|
|
1538
2646
|
}
|
|
1539
2647
|
|
|
1540
2648
|
// src/commands/check.ts
|
|
2649
|
+
var LEGACY_INDEX_REL = join17(".jig", "rules.index.json");
|
|
2650
|
+
function resolveIndexPath(projectRoot) {
|
|
2651
|
+
const legacy = join17(projectRoot, LEGACY_INDEX_REL);
|
|
2652
|
+
if (existsSync10(legacy)) return legacy;
|
|
2653
|
+
return join17(assetRoot(), "rules.index.json");
|
|
2654
|
+
}
|
|
2655
|
+
function resolveMode(projectRoot) {
|
|
2656
|
+
try {
|
|
2657
|
+
const config = JSON.parse(readFileSync12(join17(projectRoot, "jig.config.json"), "utf8"));
|
|
2658
|
+
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
2659
|
+
if (modes.length === 1) return modes[0];
|
|
2660
|
+
return modes.length > 1 ? "mixed" : "unknown";
|
|
2661
|
+
} catch {
|
|
2662
|
+
return "unknown";
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
var UNSUPPORTED_STYLE_HOSTS = [".elm", ".rs", ".templ", ".gohtml"];
|
|
2666
|
+
function summariseUnscanned(files) {
|
|
2667
|
+
const hit = files.filter((f) => !isStyleBearing(f) && hasExtension(f, UNSUPPORTED_STYLE_HOSTS));
|
|
2668
|
+
if (hit.length === 0) return void 0;
|
|
2669
|
+
const extensions = [...new Set(hit.map((f) => f.slice(f.lastIndexOf(".")).toLowerCase()))].sort();
|
|
2670
|
+
return { count: hit.length, extensions };
|
|
2671
|
+
}
|
|
2672
|
+
function countSpecs() {
|
|
2673
|
+
try {
|
|
2674
|
+
return loadSpecs(join17(assetRoot(), "rules")).length;
|
|
2675
|
+
} catch {
|
|
2676
|
+
return 0;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
1541
2679
|
function check(opts) {
|
|
1542
|
-
const
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
}
|
|
1546
|
-
const indexPath = join9(target.installRoot, ".jig", "rules.index.json");
|
|
1547
|
-
const index = validateIndex(JSON.parse(readFileSync7(indexPath, "utf8")));
|
|
1548
|
-
const tokens = loadTokenMap(target.installRoot);
|
|
1549
|
-
const { files } = selectFiles(opts.projectRoot, opts.all);
|
|
2680
|
+
const indexPath = resolveIndexPath(opts.projectRoot);
|
|
2681
|
+
const index = validateIndex(JSON.parse(readFileSync12(indexPath, "utf8")));
|
|
2682
|
+
const selection = selectFiles(opts.projectRoot, opts.all);
|
|
2683
|
+
const { files } = selection;
|
|
1550
2684
|
const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
|
|
1551
|
-
const
|
|
2685
|
+
const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
|
|
2686
|
+
const tokens = loadTokenMap(opts.projectRoot, stylesheets);
|
|
2687
|
+
const projectParticipates = stylesheets.some((f) => {
|
|
2688
|
+
if (!hasExtension(f, CSS_EXTENSIONS)) return false;
|
|
2689
|
+
try {
|
|
2690
|
+
return participatesInTokenLayer(readFileSync12(join17(opts.projectRoot, f), "utf8"), tokens);
|
|
2691
|
+
} catch {
|
|
2692
|
+
return false;
|
|
2693
|
+
}
|
|
2694
|
+
});
|
|
2695
|
+
const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates);
|
|
1552
2696
|
const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
|
|
1553
2697
|
const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
|
|
1554
2698
|
const noTokenLayer = (
|
|
@@ -1556,7 +2700,7 @@ function check(opts) {
|
|
|
1556
2700
|
// told to add a token @import it has nowhere to put.
|
|
1557
2701
|
cssFiles.length > 0 && cssFiles.every((f) => {
|
|
1558
2702
|
try {
|
|
1559
|
-
return !participatesInTokenLayer(
|
|
2703
|
+
return !participatesInTokenLayer(readFileSync12(join17(opts.projectRoot, f), "utf8"), tokens);
|
|
1560
2704
|
} catch {
|
|
1561
2705
|
return true;
|
|
1562
2706
|
}
|
|
@@ -1564,19 +2708,25 @@ function check(opts) {
|
|
|
1564
2708
|
);
|
|
1565
2709
|
const report = formatReport(findings, {
|
|
1566
2710
|
totalRules: index.length,
|
|
2711
|
+
// From the CLI's own bundle, like `explain` — the spec count describes the
|
|
2712
|
+
// system, not whatever a project happens to have vendored. A legacy project
|
|
2713
|
+
// copy may have no `rules/` directory at all.
|
|
2714
|
+
totalSpecs: countSpecs(),
|
|
1567
2715
|
version: opts.version,
|
|
1568
|
-
noTokenLayer
|
|
2716
|
+
noTokenLayer,
|
|
2717
|
+
mode: resolveMode(opts.projectRoot),
|
|
2718
|
+
unscanned: summariseUnscanned(files)
|
|
1569
2719
|
});
|
|
1570
2720
|
return { findings, report, hasError };
|
|
1571
2721
|
}
|
|
1572
2722
|
|
|
1573
2723
|
// src/commands/init.ts
|
|
1574
|
-
import { existsSync as
|
|
1575
|
-
import { dirname as dirname6, join as
|
|
2724
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync16, writeFileSync as writeFileSync4 } from "fs";
|
|
2725
|
+
import { dirname as dirname6, join as join21 } from "path";
|
|
1576
2726
|
|
|
1577
2727
|
// src/init/detect.ts
|
|
1578
|
-
import { readFileSync as
|
|
1579
|
-
import { join as
|
|
2728
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
2729
|
+
import { join as join18 } from "path";
|
|
1580
2730
|
var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
|
|
1581
2731
|
var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
|
|
1582
2732
|
var TAILWIND_V4_THEME_RE = /@theme\b/;
|
|
@@ -1593,7 +2743,7 @@ var FRAMEWORK_DEPS = [
|
|
|
1593
2743
|
function detectFramework(projectRoot) {
|
|
1594
2744
|
let pkg;
|
|
1595
2745
|
try {
|
|
1596
|
-
pkg = JSON.parse(
|
|
2746
|
+
pkg = JSON.parse(readFileSync13(join18(projectRoot, "package.json"), "utf8"));
|
|
1597
2747
|
} catch {
|
|
1598
2748
|
return void 0;
|
|
1599
2749
|
}
|
|
@@ -1611,7 +2761,7 @@ function detect(projectRoot) {
|
|
|
1611
2761
|
for (const f of cssFiles) {
|
|
1612
2762
|
let source;
|
|
1613
2763
|
try {
|
|
1614
|
-
source =
|
|
2764
|
+
source = readFileSync13(join18(projectRoot, f), "utf8");
|
|
1615
2765
|
} catch {
|
|
1616
2766
|
continue;
|
|
1617
2767
|
}
|
|
@@ -1628,9 +2778,26 @@ function detect(projectRoot) {
|
|
|
1628
2778
|
return { cssSystem, cssFiles, tailwindConfigFile, tailwindV4EntryFile, framework: detectFramework(projectRoot) };
|
|
1629
2779
|
}
|
|
1630
2780
|
|
|
2781
|
+
// src/init/gitignore.ts
|
|
2782
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
2783
|
+
function isIgnored(projectRoot, relPath) {
|
|
2784
|
+
const candidates = relPath.endsWith("/") ? [relPath] : [relPath, `${relPath}/`];
|
|
2785
|
+
return candidates.some((path) => {
|
|
2786
|
+
try {
|
|
2787
|
+
execFileSync2("git", ["check-ignore", "-q", "--", path], {
|
|
2788
|
+
cwd: projectRoot,
|
|
2789
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
2790
|
+
});
|
|
2791
|
+
return true;
|
|
2792
|
+
} catch {
|
|
2793
|
+
return false;
|
|
2794
|
+
}
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
|
|
1631
2798
|
// src/init/derive.ts
|
|
1632
|
-
import { readFileSync as
|
|
1633
|
-
import { join as
|
|
2799
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
2800
|
+
import { join as join19 } from "path";
|
|
1634
2801
|
var DEFAULT_PROPOSAL = {
|
|
1635
2802
|
h: 264,
|
|
1636
2803
|
s: 0,
|
|
@@ -1663,7 +2830,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
|
|
|
1663
2830
|
for (const file of cssFiles) {
|
|
1664
2831
|
let source;
|
|
1665
2832
|
try {
|
|
1666
|
-
source =
|
|
2833
|
+
source = readFileSync14(join19(projectRoot, file), "utf8");
|
|
1667
2834
|
} catch {
|
|
1668
2835
|
continue;
|
|
1669
2836
|
}
|
|
@@ -1712,7 +2879,7 @@ function balancedBraceBody(source, openIdx) {
|
|
|
1712
2879
|
function fromTailwindConfig(projectRoot, tailwindConfigFile) {
|
|
1713
2880
|
let source;
|
|
1714
2881
|
try {
|
|
1715
|
-
source =
|
|
2882
|
+
source = readFileSync14(join19(projectRoot, tailwindConfigFile), "utf8");
|
|
1716
2883
|
} catch {
|
|
1717
2884
|
return null;
|
|
1718
2885
|
}
|
|
@@ -1768,7 +2935,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
|
|
|
1768
2935
|
for (const file of cssFiles) {
|
|
1769
2936
|
let source;
|
|
1770
2937
|
try {
|
|
1771
|
-
source =
|
|
2938
|
+
source = readFileSync14(join19(projectRoot, file), "utf8");
|
|
1772
2939
|
} catch {
|
|
1773
2940
|
continue;
|
|
1774
2941
|
}
|
|
@@ -1818,6 +2985,20 @@ function worstRatio(h, s, l) {
|
|
|
1818
2985
|
const vsFill = contrastRatio(rgb, FILL_COMPOSITED);
|
|
1819
2986
|
return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
|
|
1820
2987
|
}
|
|
2988
|
+
var DARK_BRAND_L = 88;
|
|
2989
|
+
var DARK_SURFACE_S = 6;
|
|
2990
|
+
var DARK_RAISED_L = 15;
|
|
2991
|
+
var DARK_BASE_L = 10;
|
|
2992
|
+
var DARK_FILL_ALPHA = 0.06;
|
|
2993
|
+
function darkWorstRatio(h, s) {
|
|
2994
|
+
const brand = toRgb(h, s, DARK_BRAND_L);
|
|
2995
|
+
const raised = toRgb(h, DARK_SURFACE_S, DARK_RAISED_L);
|
|
2996
|
+
const base = toRgb(h, DARK_SURFACE_S, DARK_BASE_L);
|
|
2997
|
+
const fill = compositeOver({ r: 255, g: 255, b: 255 }, DARK_FILL_ALPHA, base);
|
|
2998
|
+
const vsRaised = contrastRatio(brand, raised);
|
|
2999
|
+
const vsFill = contrastRatio(brand, fill);
|
|
3000
|
+
return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
|
|
3001
|
+
}
|
|
1821
3002
|
function nearestPassingLightness(h, s, l) {
|
|
1822
3003
|
if (worstRatio(h, s, l).worst >= CONTRAST_FLOOR) return l;
|
|
1823
3004
|
for (let delta = 1; delta <= 100; delta++) {
|
|
@@ -1847,11 +3028,19 @@ function systemCollision(h, s, l) {
|
|
|
1847
3028
|
return null;
|
|
1848
3029
|
}
|
|
1849
3030
|
function validateBrandColor(h, s, l) {
|
|
1850
|
-
const
|
|
3031
|
+
const light = worstRatio(h, s, l);
|
|
3032
|
+
const dark = darkWorstRatio(h, s);
|
|
3033
|
+
const worst = Math.min(light.worst, dark.worst);
|
|
1851
3034
|
const passesContrast = worst >= CONTRAST_FLOOR;
|
|
1852
3035
|
return {
|
|
1853
|
-
ratioVsRaised: vsRaised,
|
|
1854
|
-
ratioVsFill: vsFill,
|
|
3036
|
+
ratioVsRaised: light.vsRaised,
|
|
3037
|
+
ratioVsFill: light.vsFill,
|
|
3038
|
+
lightRatioVsRaised: light.vsRaised,
|
|
3039
|
+
lightRatioVsFill: light.vsFill,
|
|
3040
|
+
lightWorstRatio: light.worst,
|
|
3041
|
+
darkRatioVsRaised: dark.vsRaised,
|
|
3042
|
+
darkRatioVsFill: dark.vsFill,
|
|
3043
|
+
darkWorstRatio: dark.worst,
|
|
1855
3044
|
worstRatio: worst,
|
|
1856
3045
|
passesContrast,
|
|
1857
3046
|
nearestPassingLightness: passesContrast ? void 0 : nearestPassingLightness(h, s, l) ?? void 0,
|
|
@@ -1868,7 +3057,7 @@ function round2(n) {
|
|
|
1868
3057
|
}
|
|
1869
3058
|
function renderBrandFile(defaultCssRaw, projectSlug, version2, proposal) {
|
|
1870
3059
|
const fileName = `brand.${projectSlug}.css`;
|
|
1871
|
-
const header = vendorHeader(fileName, version2, "css");
|
|
3060
|
+
const header = vendorHeader(fileName, version2, "css", null);
|
|
1872
3061
|
if (!defaultCssRaw.includes(TOP_COMMENT_FIRST_LINE) || !defaultCssRaw.includes(TOP_COMMENT_LAST_LINE)) {
|
|
1873
3062
|
throw new Error(
|
|
1874
3063
|
`brand-file: tokens/brand.default.css does not match the expected shape \u2014 ${TOP_COMMENT_FIRST_LINE} / ${TOP_COMMENT_LAST_LINE} not found.`
|
|
@@ -1906,8 +3095,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
|
|
|
1906
3095
|
}
|
|
1907
3096
|
|
|
1908
3097
|
// src/init/project-name.ts
|
|
1909
|
-
import { readFileSync as
|
|
1910
|
-
import { basename
|
|
3098
|
+
import { readFileSync as readFileSync15 } from "fs";
|
|
3099
|
+
import { basename, join as join20 } from "path";
|
|
1911
3100
|
function slugify(name) {
|
|
1912
3101
|
const withoutScope = name.replace(/^@[^/]+\//, "");
|
|
1913
3102
|
const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -1915,11 +3104,11 @@ function slugify(name) {
|
|
|
1915
3104
|
}
|
|
1916
3105
|
function deriveProjectSlug(projectRoot) {
|
|
1917
3106
|
try {
|
|
1918
|
-
const pkg = JSON.parse(
|
|
3107
|
+
const pkg = JSON.parse(readFileSync15(join20(projectRoot, "package.json"), "utf8"));
|
|
1919
3108
|
if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
|
|
1920
3109
|
} catch {
|
|
1921
3110
|
}
|
|
1922
|
-
return slugify(
|
|
3111
|
+
return slugify(basename(projectRoot));
|
|
1923
3112
|
}
|
|
1924
3113
|
|
|
1925
3114
|
// src/commands/init.ts
|
|
@@ -1939,13 +3128,15 @@ function validateRounded(p) {
|
|
|
1939
3128
|
}
|
|
1940
3129
|
function describeValidation(v) {
|
|
1941
3130
|
const lines = [];
|
|
3131
|
+
const modes = `light ${v.lightWorstRatio.toFixed(2)}:1, dark ${v.darkWorstRatio.toFixed(2)}:1`;
|
|
1942
3132
|
if (v.passesContrast) {
|
|
1943
3133
|
lines.push(
|
|
1944
|
-
` contrast OK \u2014 ${v.ratioVsRaised.toFixed(2)}
|
|
3134
|
+
` 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)`
|
|
1945
3135
|
);
|
|
1946
3136
|
} else {
|
|
3137
|
+
const failing = v.lightWorstRatio < v.darkWorstRatio ? "light" : "dark";
|
|
1947
3138
|
lines.push(
|
|
1948
|
-
` contrast FAILS \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${
|
|
3139
|
+
` contrast FAILS in ${failing} mode \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${modes})`
|
|
1949
3140
|
);
|
|
1950
3141
|
if (v.nearestPassingLightness !== void 0) {
|
|
1951
3142
|
lines.push(` nearest passing lightness at the same hue/saturation: ${v.nearestPassingLightness}%`);
|
|
@@ -1979,7 +3170,7 @@ function parseSurfaces(answer, fallback) {
|
|
|
1979
3170
|
return parsed.length > 0 ? parsed : fallback;
|
|
1980
3171
|
}
|
|
1981
3172
|
function fileState(projectRoot, absPath, relPath, initManifest) {
|
|
1982
|
-
const existsOnDisk =
|
|
3173
|
+
const existsOnDisk = existsSync11(absPath);
|
|
1983
3174
|
const tracked = !!initManifest && relPath in initManifest.files;
|
|
1984
3175
|
const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
|
|
1985
3176
|
return { existsOnDisk, tracked, modified };
|
|
@@ -1990,11 +3181,11 @@ function isValidSurfaceArray(v) {
|
|
|
1990
3181
|
);
|
|
1991
3182
|
}
|
|
1992
3183
|
function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
|
|
1993
|
-
if (!
|
|
3184
|
+
if (!existsSync11(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
1994
3185
|
try {
|
|
1995
|
-
const parsed = JSON.parse(
|
|
3186
|
+
const parsed = JSON.parse(readFileSync16(configAbsPath, "utf8"));
|
|
1996
3187
|
const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
|
|
1997
|
-
const brandResolves = brandCandidate ?
|
|
3188
|
+
const brandResolves = brandCandidate ? existsSync11(join21(projectRoot, ...brandCandidate.split("/"))) : false;
|
|
1998
3189
|
return {
|
|
1999
3190
|
brand: brandResolves ? brandCandidate : fallbackBrand,
|
|
2000
3191
|
surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
|
|
@@ -2020,7 +3211,7 @@ function insertAfterCharset(content, insertion) {
|
|
|
2020
3211
|
return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
|
|
2021
3212
|
}
|
|
2022
3213
|
function wireImport(absPath, brandImport, modeImport, mode) {
|
|
2023
|
-
const content =
|
|
3214
|
+
const content = existsSync11(absPath) ? readFileSync16(absPath, "utf8") : "";
|
|
2024
3215
|
const brandImportLine = `@import "${brandImport}";`;
|
|
2025
3216
|
const modeImportLine = `@import "${modeImport}";`;
|
|
2026
3217
|
const brandPresent = content.includes(brandImportLine);
|
|
@@ -2033,7 +3224,7 @@ function wireImport(absPath, brandImport, modeImport, mode) {
|
|
|
2033
3224
|
const previousMode = existingModeMatch[2];
|
|
2034
3225
|
const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
|
|
2035
3226
|
` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
|
|
2036
|
-
|
|
3227
|
+
writeFileSync4(absPath, next3, "utf8");
|
|
2037
3228
|
return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
|
|
2038
3229
|
}
|
|
2039
3230
|
const brandIdx = content.indexOf(brandImportLine);
|
|
@@ -2041,7 +3232,7 @@ function wireImport(absPath, brandImport, modeImport, mode) {
|
|
|
2041
3232
|
if (content[insertAt] === "\r") insertAt++;
|
|
2042
3233
|
if (content[insertAt] === "\n") insertAt++;
|
|
2043
3234
|
const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
|
|
2044
|
-
|
|
3235
|
+
writeFileSync4(absPath, next2, "utf8");
|
|
2045
3236
|
return { status: "rewired", detail: `added missing mode import: ${mode}` };
|
|
2046
3237
|
}
|
|
2047
3238
|
const lines = `${brandImportLine}
|
|
@@ -2057,16 +3248,31 @@ ${modeImportLine}
|
|
|
2057
3248
|
` : lines);
|
|
2058
3249
|
}
|
|
2059
3250
|
mkdirSync5(dirname6(absPath), { recursive: true });
|
|
2060
|
-
|
|
3251
|
+
writeFileSync4(absPath, next, "utf8");
|
|
2061
3252
|
return { status: "wired" };
|
|
2062
3253
|
}
|
|
2063
3254
|
async function init(opts) {
|
|
2064
3255
|
const log = opts.log ?? ((line) => console.log(line));
|
|
2065
3256
|
const prompt = opts.prompt ?? defaultPrompt;
|
|
2066
|
-
const
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
3257
|
+
const migrateLegacy = async (report, describe) => {
|
|
3258
|
+
if (!report.present) return;
|
|
3259
|
+
for (const line of describe(report)) log(line);
|
|
3260
|
+
const removable = removableLegacyFiles(report);
|
|
3261
|
+
if (removable.length === 0) return;
|
|
3262
|
+
if (opts.yes) {
|
|
3263
|
+
log(" Re-run without --yes to be prompted for removal, or delete them yourself.");
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
const answer = (await prompt(` Remove these ${removable.length} unedited legacy file(s)? [y/N]: `)).toLowerCase();
|
|
3267
|
+
if (answer === "y" || answer === "yes") {
|
|
3268
|
+
removeLegacyFiles(opts.projectRoot, removable);
|
|
3269
|
+
log(` Removed ${removable.length} file(s).`);
|
|
3270
|
+
} else {
|
|
3271
|
+
log(" Left them in place.");
|
|
3272
|
+
}
|
|
3273
|
+
};
|
|
3274
|
+
await migrateLegacy(detectLegacyRules(opts.projectRoot), describeLegacyReport);
|
|
3275
|
+
await migrateLegacy(detectLegacyCursorRules(opts.projectRoot), describeLegacyCursorReport);
|
|
2070
3276
|
const detection = detect(opts.projectRoot);
|
|
2071
3277
|
log(`Detected: ${detection.cssSystem}${detection.framework ? ` (${detection.framework})` : ""}`);
|
|
2072
3278
|
if (detection.cssFiles.length === 0) {
|
|
@@ -2107,6 +3313,11 @@ async function init(opts) {
|
|
|
2107
3313
|
);
|
|
2108
3314
|
}
|
|
2109
3315
|
}
|
|
3316
|
+
if (opts.yes) {
|
|
3317
|
+
log(
|
|
3318
|
+
`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("/")}).`
|
|
3319
|
+
);
|
|
3320
|
+
}
|
|
2110
3321
|
if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
|
|
2111
3322
|
const originalL = proposal.l;
|
|
2112
3323
|
proposal = { ...proposal, l: validation.nearestPassingLightness };
|
|
@@ -2119,9 +3330,9 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
|
|
|
2119
3330
|
const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
|
|
2120
3331
|
const projectSlug = deriveProjectSlug(opts.projectRoot);
|
|
2121
3332
|
const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
|
|
2122
|
-
const brandAbsPath =
|
|
3333
|
+
const brandAbsPath = join21(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
|
|
2123
3334
|
const configRelPath = "jig.config.json";
|
|
2124
|
-
const configAbsPath =
|
|
3335
|
+
const configAbsPath = join21(opts.projectRoot, configRelPath);
|
|
2125
3336
|
const initManifest = readInitManifest(opts.projectRoot);
|
|
2126
3337
|
const files = { ...initManifest?.files ?? {} };
|
|
2127
3338
|
const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
|
|
@@ -2150,68 +3361,72 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
2150
3361
|
if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
|
|
2151
3362
|
}
|
|
2152
3363
|
if (brandAction === "written") {
|
|
2153
|
-
const defaultCssRaw =
|
|
3364
|
+
const defaultCssRaw = readFileSync16(join21(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
|
|
2154
3365
|
const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
|
|
2155
3366
|
mkdirSync5(dirname6(brandAbsPath), { recursive: true });
|
|
2156
|
-
|
|
3367
|
+
writeFileSync4(brandAbsPath, content, "utf8");
|
|
2157
3368
|
files[brandRelPath] = checksum(content);
|
|
2158
3369
|
}
|
|
2159
3370
|
if (configAction === "written") {
|
|
2160
3371
|
const config = { brand: brandRelPath, surfaces };
|
|
2161
3372
|
const content = `${JSON.stringify(config, null, 2)}
|
|
2162
3373
|
`;
|
|
2163
|
-
|
|
3374
|
+
writeFileSync4(configAbsPath, content, "utf8");
|
|
2164
3375
|
files[configRelPath] = checksum(content);
|
|
2165
3376
|
} else if (configAction === "merged") {
|
|
2166
3377
|
let existing = {};
|
|
2167
3378
|
try {
|
|
2168
|
-
existing = JSON.parse(
|
|
3379
|
+
existing = JSON.parse(readFileSync16(configAbsPath, "utf8"));
|
|
2169
3380
|
} catch {
|
|
2170
3381
|
existing = {};
|
|
2171
3382
|
}
|
|
2172
3383
|
const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
|
|
2173
|
-
const brandStillResolves = existingBrand ?
|
|
3384
|
+
const brandStillResolves = existingBrand ? existsSync11(join21(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
2174
3385
|
const merged = {
|
|
2175
3386
|
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
2176
3387
|
surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
|
|
2177
3388
|
};
|
|
2178
3389
|
const content = `${JSON.stringify(merged, null, 2)}
|
|
2179
3390
|
`;
|
|
2180
|
-
|
|
3391
|
+
writeFileSync4(configAbsPath, content, "utf8");
|
|
2181
3392
|
files[configRelPath] = checksum(content);
|
|
2182
3393
|
}
|
|
2183
3394
|
const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
|
|
2184
3395
|
const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
|
|
2185
|
-
const wiringBrandAbsPath =
|
|
2186
|
-
const
|
|
2187
|
-
const
|
|
2188
|
-
|
|
3396
|
+
const wiringBrandAbsPath = join21(opts.projectRoot, ...effectiveConfig.brand.split("/"));
|
|
3397
|
+
const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
|
|
3398
|
+
const modeAbsPaths = {};
|
|
3399
|
+
for (const mode of declaredModes) {
|
|
3400
|
+
const modeFileName = `mode.${mode}.css`;
|
|
3401
|
+
const modeAbsPath2 = join21(opts.projectRoot, ".jig", "tokens", modeFileName);
|
|
3402
|
+
modeAbsPaths[mode] = modeAbsPath2;
|
|
2189
3403
|
const modeRelPath = relKey(".jig", "tokens", modeFileName);
|
|
2190
|
-
const modeState = fileState(opts.projectRoot,
|
|
3404
|
+
const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
|
|
2191
3405
|
let modeAction = "written";
|
|
2192
3406
|
if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
|
|
2193
3407
|
else if (modeState.modified) modeAction = "skipped-edited";
|
|
2194
3408
|
if (modeAction === "written") {
|
|
2195
|
-
const modeSourceRaw =
|
|
2196
|
-
const content = vendorHeader(modeFileName, opts.version, "css") + modeSourceRaw;
|
|
2197
|
-
mkdirSync5(dirname6(
|
|
2198
|
-
|
|
3409
|
+
const modeSourceRaw = readFileSync16(join21(opts.packageRoot, "tokens", modeFileName), "utf8");
|
|
3410
|
+
const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
|
|
3411
|
+
mkdirSync5(dirname6(modeAbsPath2), { recursive: true });
|
|
3412
|
+
writeFileSync4(modeAbsPath2, content, "utf8");
|
|
2199
3413
|
files[modeRelPath] = checksum(content);
|
|
2200
3414
|
} else {
|
|
2201
3415
|
log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
|
|
2202
3416
|
}
|
|
2203
3417
|
}
|
|
2204
|
-
|
|
3418
|
+
const modeAbsPath = modeAbsPaths[primaryMode];
|
|
3419
|
+
writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
|
|
2205
3420
|
const wireTarget = findWireTarget(detection);
|
|
2206
3421
|
let wiring;
|
|
2207
3422
|
if (wireTarget) {
|
|
2208
|
-
const targetAbsDir = dirname6(
|
|
3423
|
+
const targetAbsDir = dirname6(join21(opts.projectRoot, wireTarget));
|
|
2209
3424
|
const brandImport = relativeImportPath(targetAbsDir, wiringBrandAbsPath);
|
|
2210
3425
|
const modeImport = relativeImportPath(targetAbsDir, modeAbsPath);
|
|
2211
3426
|
const snippet = `@import "${brandImport}";
|
|
2212
3427
|
@import "${modeImport}";`;
|
|
2213
3428
|
try {
|
|
2214
|
-
const outcome = wireImport(
|
|
3429
|
+
const outcome = wireImport(join21(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
|
|
2215
3430
|
wiring = { target: wireTarget, status: outcome.status, snippet };
|
|
2216
3431
|
const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
|
|
2217
3432
|
log(`
|
|
@@ -2231,11 +3446,29 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
2231
3446
|
const snippet = `@import "${brandImport}";
|
|
2232
3447
|
@import "${modeImport}";`;
|
|
2233
3448
|
wiring = { target: null, status: "print-only", snippet };
|
|
3449
|
+
log("\nCould not find a single unambiguous stylesheet to wire the import into.");
|
|
3450
|
+
const pasteTargets = detection.cssFiles.filter((f) => !isCssModule(f));
|
|
3451
|
+
if (pasteTargets.length > 0) {
|
|
3452
|
+
log("Paste the block for whichever of these is your global stylesheet:");
|
|
3453
|
+
for (const target of pasteTargets) {
|
|
3454
|
+
const dir = dirname6(join21(opts.projectRoot, target));
|
|
3455
|
+
const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
|
|
3456
|
+
@import "${relativeImportPath(dir, modeAbsPath)}";`;
|
|
3457
|
+
log(`
|
|
3458
|
+
${target}:`);
|
|
3459
|
+
log(` ${forTarget.split("\n").join("\n ")}`);
|
|
3460
|
+
}
|
|
3461
|
+
} else {
|
|
3462
|
+
log(
|
|
3463
|
+
"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:"
|
|
3464
|
+
);
|
|
3465
|
+
log(` ${snippet.split("\n").join("\n ")}`);
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
if (isIgnored(opts.projectRoot, ".jig")) {
|
|
2234
3469
|
log(
|
|
2235
|
-
|
|
2236
|
-
Could not find a single unambiguous stylesheet to wire the import into. Add this near the top of your global stylesheet (paths shown relative to the project root \u2014 adjust to wherever you paste them):`
|
|
3470
|
+
"\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."
|
|
2237
3471
|
);
|
|
2238
|
-
log(` ${snippet.split("\n").join("\n ")}`);
|
|
2239
3472
|
}
|
|
2240
3473
|
const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
|
|
2241
3474
|
log("\nBaseline:");
|
|
@@ -2259,7 +3492,13 @@ Could not find a single unambiguous stylesheet to wire the import into. Add this
|
|
|
2259
3492
|
|
|
2260
3493
|
// src/index.ts
|
|
2261
3494
|
var packageRoot = getPackageRoot();
|
|
2262
|
-
var { version } = JSON.parse(
|
|
3495
|
+
var { version } = JSON.parse(readFileSync17(join22(packageRoot, "package.json"), "utf8"));
|
|
3496
|
+
function warnIfUnpublishedPin() {
|
|
3497
|
+
if (isPublishedBuild(packageRoot)) return;
|
|
3498
|
+
console.warn(
|
|
3499
|
+
`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.`
|
|
3500
|
+
);
|
|
3501
|
+
}
|
|
2263
3502
|
var program = new Command();
|
|
2264
3503
|
program.name("jig").description("A design system for coding agents.").version(version);
|
|
2265
3504
|
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) => {
|
|
@@ -2277,7 +3516,12 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
2277
3516
|
version,
|
|
2278
3517
|
homeDir: homedir()
|
|
2279
3518
|
});
|
|
3519
|
+
if (result.warning) {
|
|
3520
|
+
console.warn(result.warning);
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
2280
3523
|
console.log(`Installed Jig v${version} for ${opts.agent} (${opts.scope} scope)`);
|
|
3524
|
+
warnIfUnpublishedPin();
|
|
2281
3525
|
for (const f of result.written) console.log(` + ${f}`);
|
|
2282
3526
|
for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
|
|
2283
3527
|
} catch (err) {
|
|
@@ -2285,6 +3529,14 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
2285
3529
|
process.exit(1);
|
|
2286
3530
|
}
|
|
2287
3531
|
});
|
|
3532
|
+
program.command("explain").description("Explain a rule, or find the rules you cannot name.").argument("[query]", "a rule id ('C-19'), a word to search for ('contrast'), or a section letter with --list").option("--list", "list every rule id and title, or one section with a section letter").action((query, options) => {
|
|
3533
|
+
try {
|
|
3534
|
+
console.log(explain({ ruleId: query ?? "", version, list: options.list }));
|
|
3535
|
+
} catch (err) {
|
|
3536
|
+
console.error(err.message);
|
|
3537
|
+
process.exit(1);
|
|
3538
|
+
}
|
|
3539
|
+
});
|
|
2288
3540
|
program.command("update").description("Update vendored Jig rules, skipping files you have edited.").action(() => {
|
|
2289
3541
|
const projectRoot = findProjectRoot(process.cwd());
|
|
2290
3542
|
try {
|
|
@@ -2296,7 +3548,11 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
2296
3548
|
version,
|
|
2297
3549
|
homeDir: homedir()
|
|
2298
3550
|
});
|
|
2299
|
-
|
|
3551
|
+
const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
|
|
3552
|
+
warnIfUnpublishedPin();
|
|
3553
|
+
console.log(
|
|
3554
|
+
`Updated Jig \u2192 ${result.toVersion} in ${result.targets.length} harness${result.targets.length === 1 ? "" : "es"}: ${label}`
|
|
3555
|
+
);
|
|
2300
3556
|
for (const f of result.updated) console.log(` ~ ${f}`);
|
|
2301
3557
|
for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
|
|
2302
3558
|
} catch (err) {
|