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