jig-ui 0.3.0 → 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 +268 -31
- package/dist/index.js +1476 -315
- 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 +5 -5
- 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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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);
|
|
505
|
+
}
|
|
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({ key: relKey(".jig", file), content: vendorHeader(file, opts.version) + body, checkable: true });
|
|
368
|
-
}
|
|
369
|
-
const tokensDir = join3(opts.packageRoot, "tokens");
|
|
370
|
-
for (const file of readdirSync(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
371
|
-
const body = readFileSync2(join3(tokensDir, file), "utf8");
|
|
565
|
+
const rulesDir = join6(opts.packageRoot, "rules");
|
|
566
|
+
for (const file of readdirSync2(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
567
|
+
const body = readFileSync3(join6(rulesDir, file), "utf8");
|
|
372
568
|
planned.push({
|
|
373
|
-
key: relKey(
|
|
374
|
-
content: vendorHeader(file, opts.version, "
|
|
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)) {
|
|
575
|
+
planned.push({
|
|
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,118 +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
|
|
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";
|
|
432
644
|
|
|
433
645
|
// src/install/target.ts
|
|
434
646
|
import { resolve } from "path";
|
|
435
|
-
function
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
if (!existing) return null;
|
|
647
|
+
function resolveAllInstalled(projectRoot, homeDir) {
|
|
648
|
+
const found = [];
|
|
649
|
+
const seen = /* @__PURE__ */ new Set();
|
|
439
650
|
const sameRoot = resolve(projectRoot) === resolve(homeDir);
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
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;
|
|
443
683
|
}
|
|
444
684
|
|
|
445
685
|
// src/init/state.ts
|
|
446
|
-
import { existsSync as
|
|
447
|
-
import { dirname as
|
|
448
|
-
var INIT_MANIFEST_REL =
|
|
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");
|
|
449
689
|
function readInitManifest(projectRoot) {
|
|
450
|
-
const path =
|
|
451
|
-
if (!
|
|
690
|
+
const path = join7(projectRoot, INIT_MANIFEST_REL);
|
|
691
|
+
if (!existsSync5(path)) return null;
|
|
452
692
|
try {
|
|
453
|
-
const raw = JSON.parse(
|
|
693
|
+
const raw = JSON.parse(readFileSync4(path, "utf8"));
|
|
454
694
|
if (typeof raw !== "object" || raw === null) return null;
|
|
455
|
-
const files = raw
|
|
695
|
+
const { files, version: version2, modes } = raw;
|
|
456
696
|
if (typeof files !== "object" || files === null) return null;
|
|
457
|
-
return {
|
|
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
|
+
};
|
|
458
702
|
} catch {
|
|
459
703
|
return null;
|
|
460
704
|
}
|
|
461
705
|
}
|
|
706
|
+
var MERGE_ATTEMPTS2 = 5;
|
|
462
707
|
function writeInitManifest(projectRoot, m) {
|
|
463
|
-
const path =
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
+
}
|
|
467
722
|
}
|
|
468
723
|
function isInitFileModified(projectRoot, relPath, m) {
|
|
469
724
|
const asManifest = { version: "", agent: "", scope: "project", installedAt: "", files: m.files };
|
|
470
725
|
return isModified(projectRoot, relPath, asManifest);
|
|
471
726
|
}
|
|
472
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
|
+
|
|
473
831
|
// src/commands/update.ts
|
|
474
|
-
var ALWAYS_REPLACE = [relKey(".jig", "LICENSE"), relKey(".jig", "NOTICE")];
|
|
475
832
|
function update(opts) {
|
|
476
|
-
const resolved =
|
|
477
|
-
if (
|
|
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.` : "";
|
|
478
837
|
throw new Error(
|
|
479
|
-
`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}`
|
|
480
839
|
);
|
|
481
840
|
}
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
);
|
|
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
|
+
});
|
|
488
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);
|
|
489
869
|
const updated = [];
|
|
490
870
|
const skipped = [];
|
|
491
871
|
const files = { ...existing.files };
|
|
492
872
|
const write = (key, content) => {
|
|
493
|
-
const abs =
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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);
|
|
497
879
|
updated.push(key);
|
|
498
880
|
};
|
|
499
|
-
const rulesDir =
|
|
500
|
-
for (const file of
|
|
501
|
-
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);
|
|
502
884
|
if (isModified(installRoot, key, existing)) {
|
|
503
885
|
skipped.push(key);
|
|
504
886
|
continue;
|
|
505
887
|
}
|
|
506
|
-
write(key, vendorHeader(file, opts.version) +
|
|
888
|
+
write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync6(join9(rulesDir, file), "utf8"));
|
|
507
889
|
}
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
const key = relKey(".jig", "tokens", file);
|
|
890
|
+
for (const ref of referenceFiles(opts.packageRoot)) {
|
|
891
|
+
const key = relKey(referenceDir, ...ref.relPath.split("/"));
|
|
511
892
|
if (isModified(installRoot, key, existing)) {
|
|
512
893
|
skipped.push(key);
|
|
513
894
|
continue;
|
|
514
895
|
}
|
|
515
|
-
write(key, vendorHeader(
|
|
896
|
+
write(key, vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content);
|
|
516
897
|
}
|
|
517
|
-
const indexKey = relKey(
|
|
898
|
+
const indexKey = relKey(referenceDir, "rules.index.json");
|
|
518
899
|
if (isModified(installRoot, indexKey, existing)) {
|
|
519
900
|
skipped.push(indexKey);
|
|
520
901
|
} else {
|
|
521
|
-
write(indexKey,
|
|
522
|
-
}
|
|
523
|
-
const
|
|
902
|
+
write(indexKey, readFileSync6(join9(opts.packageRoot, "rules.index.json"), "utf8"));
|
|
903
|
+
}
|
|
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 };
|
|
524
913
|
const skillFiles = skillFilesFor(adapter, {
|
|
525
914
|
version: opts.version,
|
|
526
915
|
scope: discoveredScope,
|
|
527
916
|
skillBody,
|
|
528
|
-
commandPrefix: "/jig "
|
|
917
|
+
commandPrefix: "/jig ",
|
|
918
|
+
...command
|
|
529
919
|
});
|
|
530
920
|
for (const file of skillFiles) {
|
|
531
921
|
const key = file.relPath;
|
|
532
922
|
const isBlockFile = file.content.includes(BLOCK_START);
|
|
533
923
|
if (isBlockFile) {
|
|
534
|
-
const abs =
|
|
535
|
-
const current =
|
|
924
|
+
const abs = join9(installRoot, ...key.split("/"));
|
|
925
|
+
const current = existsSync7(abs) ? readFileSync6(abs, "utf8") : "";
|
|
536
926
|
write(key, upsertBlock(current, file.content));
|
|
537
927
|
continue;
|
|
538
928
|
}
|
|
@@ -542,44 +932,90 @@ function update(opts) {
|
|
|
542
932
|
}
|
|
543
933
|
write(key, file.content);
|
|
544
934
|
}
|
|
545
|
-
for (const
|
|
546
|
-
write(
|
|
935
|
+
for (const src of ["LICENSE", "NOTICE"]) {
|
|
936
|
+
write(relKey(referenceDir, src), readFileSync6(join9(opts.packageRoot, src), "utf8"));
|
|
547
937
|
}
|
|
938
|
+
const manifest = {
|
|
939
|
+
...existing,
|
|
940
|
+
scope: discoveredScope,
|
|
941
|
+
version: opts.version,
|
|
942
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
943
|
+
files
|
|
944
|
+
};
|
|
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");
|
|
548
952
|
const initManifest = readInitManifest(opts.projectRoot);
|
|
549
953
|
if (initManifest) {
|
|
550
954
|
const initFiles = { ...initManifest.files };
|
|
551
955
|
let initChanged = false;
|
|
552
|
-
for (const file of
|
|
956
|
+
for (const file of readdirSync4(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
553
957
|
const key = relKey(".jig", "tokens", file);
|
|
554
958
|
if (!(key in initManifest.files)) continue;
|
|
555
959
|
if (isInitFileModified(opts.projectRoot, key, initManifest)) {
|
|
556
960
|
skipped.push(key);
|
|
557
961
|
continue;
|
|
558
962
|
}
|
|
559
|
-
const content = vendorHeader(file, opts.version, "css") +
|
|
560
|
-
const abs =
|
|
561
|
-
|
|
562
|
-
|
|
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");
|
|
563
967
|
initFiles[key] = checksum(content);
|
|
564
968
|
initChanged = true;
|
|
565
969
|
updated.push(key);
|
|
566
970
|
}
|
|
567
|
-
if (initChanged) writeInitManifest(opts.projectRoot, { files: initFiles });
|
|
971
|
+
if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
|
|
568
972
|
}
|
|
569
|
-
|
|
570
|
-
...existing,
|
|
571
|
-
scope: discoveredScope,
|
|
572
|
-
version: opts.version,
|
|
573
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
574
|
-
files
|
|
575
|
-
};
|
|
576
|
-
writeManifest(installRoot, manifest);
|
|
577
|
-
return { updated, skipped, fromVersion: existing.version, toVersion: opts.version };
|
|
973
|
+
return { updated, skipped };
|
|
578
974
|
}
|
|
579
975
|
|
|
580
|
-
// src/commands/
|
|
581
|
-
import {
|
|
582
|
-
|
|
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
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
push();
|
|
1017
|
+
return rules;
|
|
1018
|
+
}
|
|
583
1019
|
|
|
584
1020
|
// src/rules/schema.ts
|
|
585
1021
|
var BUCKETS = ["mechanical", "judgment", "hybrid"];
|
|
@@ -612,10 +1048,130 @@ function validateIndex(entries) {
|
|
|
612
1048
|
});
|
|
613
1049
|
}
|
|
614
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
|
+
|
|
615
1171
|
// src/check/files.ts
|
|
616
1172
|
import { execFileSync } from "child_process";
|
|
617
|
-
import { readdirSync as
|
|
618
|
-
import { join as
|
|
1173
|
+
import { readdirSync as readdirSync7, realpathSync, statSync } from "fs";
|
|
1174
|
+
import { join as join13, relative, sep } from "path";
|
|
619
1175
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
620
1176
|
"node_modules",
|
|
621
1177
|
"dist",
|
|
@@ -671,19 +1227,38 @@ function gitChangedFiles(root) {
|
|
|
671
1227
|
function isExcluded(relPath) {
|
|
672
1228
|
return relPath.split("/").some((segment) => EXCLUDE_DIRS.has(segment));
|
|
673
1229
|
}
|
|
674
|
-
function walk(root, dir, out) {
|
|
1230
|
+
function walk(root, dir, out, seen) {
|
|
675
1231
|
let entries;
|
|
676
1232
|
try {
|
|
677
|
-
entries =
|
|
1233
|
+
entries = readdirSync7(dir, { withFileTypes: true });
|
|
678
1234
|
} catch {
|
|
679
1235
|
return;
|
|
680
1236
|
}
|
|
681
1237
|
for (const entry of entries) {
|
|
682
1238
|
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
683
|
-
const abs =
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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) {
|
|
687
1262
|
out.push(relative(root, abs).split(sep).join("/"));
|
|
688
1263
|
}
|
|
689
1264
|
}
|
|
@@ -712,9 +1287,16 @@ function filterGitIgnored(root, files) {
|
|
|
712
1287
|
return files;
|
|
713
1288
|
}
|
|
714
1289
|
}
|
|
1290
|
+
function safeRealpath(p) {
|
|
1291
|
+
try {
|
|
1292
|
+
return realpathSync(p);
|
|
1293
|
+
} catch {
|
|
1294
|
+
return p;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
715
1297
|
function wholeRepoFiles(root) {
|
|
716
1298
|
const out = [];
|
|
717
|
-
walk(root, root, out);
|
|
1299
|
+
walk(root, root, out, /* @__PURE__ */ new Set([safeRealpath(root)]));
|
|
718
1300
|
return isGitRepo(root) ? filterGitIgnored(root, out) : out;
|
|
719
1301
|
}
|
|
720
1302
|
function selectFiles(root, all) {
|
|
@@ -738,7 +1320,7 @@ function formatReport(findings, meta) {
|
|
|
738
1320
|
const loc = `${f.file}:${f.line}`;
|
|
739
1321
|
const firstOccurrence = !seenRule.has(f.ruleId);
|
|
740
1322
|
seenRule.add(f.ruleId);
|
|
741
|
-
const hint = firstOccurrence ? ` (see
|
|
1323
|
+
const hint = firstOccurrence ? ` (see rule ${f.ruleId} in your installed jig skill's rules/)` : "";
|
|
742
1324
|
return { symbol, ruleId: f.ruleId, message: f.message, loc, fileOnly: f.file, bucket: f.bucket, hint };
|
|
743
1325
|
});
|
|
744
1326
|
if (rows.length > 0) {
|
|
@@ -768,9 +1350,7 @@ function formatReport(findings, meta) {
|
|
|
768
1350
|
lines.push("");
|
|
769
1351
|
if (meta.noTokenLayer) {
|
|
770
1352
|
lines.push(" No file references a Jig token, so H-47 (hard-coded values) was not run.");
|
|
771
|
-
lines.push("
|
|
772
|
-
lines.push(' @import ".jig/tokens/brand.default.css";');
|
|
773
|
-
lines.push(' @import ".jig/tokens/mode.product.css";');
|
|
1353
|
+
lines.push(" Run 'jig init' to generate a brand file and wire it (with a mode file) into your CSS.");
|
|
774
1354
|
lines.push("");
|
|
775
1355
|
}
|
|
776
1356
|
}
|
|
@@ -781,16 +1361,28 @@ function formatReport(findings, meta) {
|
|
|
781
1361
|
if (warnings > 0) summaryParts.push(plural(warnings, "warning"));
|
|
782
1362
|
if (notes > 0) summaryParts.push(plural(notes, "note"));
|
|
783
1363
|
const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
|
|
784
|
-
|
|
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
|
+
}
|
|
785
1373
|
const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
|
|
786
1374
|
const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
|
|
787
|
-
lines.push(
|
|
1375
|
+
lines.push(
|
|
1376
|
+
` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run`
|
|
1377
|
+
);
|
|
788
1378
|
return lines.join("\n");
|
|
789
1379
|
}
|
|
790
1380
|
|
|
791
1381
|
// src/check/token-layer.ts
|
|
792
1382
|
var IMPORT_RE = /@import\s+(?:url\()?["']?[^"')]*\.jig\/tokens\/[^"')]+\.css["']?\)?/i;
|
|
793
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;
|
|
794
1386
|
function participatesInTokenLayer(source, tokens) {
|
|
795
1387
|
if (IMPORT_RE.test(source)) return true;
|
|
796
1388
|
VAR_RE.lastIndex = 0;
|
|
@@ -798,12 +1390,91 @@ function participatesInTokenLayer(source, tokens) {
|
|
|
798
1390
|
while (m = VAR_RE.exec(source)) {
|
|
799
1391
|
if (Object.prototype.hasOwnProperty.call(tokens, m[1])) return true;
|
|
800
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
|
+
}
|
|
801
1403
|
return false;
|
|
802
1404
|
}
|
|
803
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
|
+
|
|
804
1475
|
// src/check/run.ts
|
|
805
|
-
import { readFileSync as
|
|
806
|
-
import { join as
|
|
1476
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
1477
|
+
import { join as join14 } from "path";
|
|
807
1478
|
|
|
808
1479
|
// src/check/css.ts
|
|
809
1480
|
function maskComments(source) {
|
|
@@ -870,7 +1541,7 @@ function splitRuleBlocks(source) {
|
|
|
870
1541
|
}
|
|
871
1542
|
function blankNested(body) {
|
|
872
1543
|
const out = body.split("");
|
|
873
|
-
const
|
|
1544
|
+
const blank2 = (from, to) => {
|
|
874
1545
|
for (let j = from; j < to; j++) if (out[j] !== "\n") out[j] = " ";
|
|
875
1546
|
};
|
|
876
1547
|
let depth = 0;
|
|
@@ -878,7 +1549,7 @@ function blankNested(body) {
|
|
|
878
1549
|
for (let i = 0; i < body.length; i++) {
|
|
879
1550
|
const ch = body[i];
|
|
880
1551
|
if (ch === "{") {
|
|
881
|
-
if (depth === 0)
|
|
1552
|
+
if (depth === 0) blank2(segmentStart, i);
|
|
882
1553
|
depth++;
|
|
883
1554
|
out[i] = " ";
|
|
884
1555
|
} else if (ch === "}") {
|
|
@@ -929,11 +1600,221 @@ function sourceLine(source, line) {
|
|
|
929
1600
|
return (lines[line - 1] ?? "").trim();
|
|
930
1601
|
}
|
|
931
1602
|
|
|
932
|
-
// src/check/
|
|
933
|
-
|
|
934
|
-
|
|
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) {
|
|
935
1658
|
const lower = file.toLowerCase();
|
|
936
|
-
|
|
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;
|
|
937
1818
|
}
|
|
938
1819
|
|
|
939
1820
|
// src/check/finding.ts
|
|
@@ -955,7 +1836,7 @@ var CLIP_RE = /(?:-webkit-)?background-clip\s*:\s*text\b/i;
|
|
|
955
1836
|
var GRADIENT_RE = /(?:background|background-image)\s*:[^;]*(?:linear|radial|conic)-gradient\s*\(/i;
|
|
956
1837
|
var gradientText = {
|
|
957
1838
|
name: "gradient-text",
|
|
958
|
-
appliesTo: (file) =>
|
|
1839
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
959
1840
|
run(source, file, ctx) {
|
|
960
1841
|
const findings = [];
|
|
961
1842
|
for (const block of leafBlocks(source)) {
|
|
@@ -1184,7 +2065,7 @@ function hasTranslucentBackground(body, tokens) {
|
|
|
1184
2065
|
}
|
|
1185
2066
|
var backdropBlur = {
|
|
1186
2067
|
name: "backdrop-blur",
|
|
1187
|
-
appliesTo: (file) =>
|
|
2068
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1188
2069
|
run(source, file, ctx) {
|
|
1189
2070
|
const findings = [];
|
|
1190
2071
|
for (const block of leafBlocks(source)) {
|
|
@@ -1215,7 +2096,7 @@ function isPure(rgb, target) {
|
|
|
1215
2096
|
}
|
|
1216
2097
|
var pureBlackWhite = {
|
|
1217
2098
|
name: "pure-black-white",
|
|
1218
|
-
appliesTo: (file) =>
|
|
2099
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1219
2100
|
run(source, file, ctx) {
|
|
1220
2101
|
const findings = [];
|
|
1221
2102
|
for (const block of leafBlocks(source)) {
|
|
@@ -1250,6 +2131,104 @@ var pureBlackWhite = {
|
|
|
1250
2131
|
}
|
|
1251
2132
|
};
|
|
1252
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
|
+
|
|
1253
2232
|
// src/check/detectors/contrast-floor.ts
|
|
1254
2233
|
var FLOOR = 4.5;
|
|
1255
2234
|
var LARGE_FLOOR = 3;
|
|
@@ -1274,7 +2253,7 @@ function resolveFloor(body) {
|
|
|
1274
2253
|
var DECL_RE = /(?<![-\w])(color|background(?:-color)?)\s*:\s*([^;]+)(?:;|$)/gi;
|
|
1275
2254
|
var contrastFloor = {
|
|
1276
2255
|
name: "contrast-floor",
|
|
1277
|
-
appliesTo: (file) =>
|
|
2256
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1278
2257
|
run(source, file, ctx) {
|
|
1279
2258
|
const findings = [];
|
|
1280
2259
|
for (const block of leafBlocks(source)) {
|
|
@@ -1311,6 +2290,25 @@ var contrastFloor = {
|
|
|
1311
2290
|
)
|
|
1312
2291
|
);
|
|
1313
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
|
+
}
|
|
1314
2312
|
return findings;
|
|
1315
2313
|
}
|
|
1316
2314
|
};
|
|
@@ -1320,7 +2318,7 @@ var OUTLINE_NONE_RE = /(?<![-\w])outline\s*:\s*(none|0(?:px)?)\s*(?:!important)?
|
|
|
1320
2318
|
var FOCUS_VISIBLE_RE = /:focus-visible\b/i;
|
|
1321
2319
|
var focusRemoved = {
|
|
1322
2320
|
name: "focus-removed",
|
|
1323
|
-
appliesTo: (file) =>
|
|
2321
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1324
2322
|
run(source, file, ctx) {
|
|
1325
2323
|
if (FOCUS_VISIBLE_RE.test(source)) return [];
|
|
1326
2324
|
const findings = [];
|
|
@@ -1381,9 +2379,10 @@ var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
|
|
|
1381
2379
|
var KEYFRAME_STEP_RE = /^(from|to|\d+(\.\d+)?%)$/i;
|
|
1382
2380
|
var hardcodedValue = {
|
|
1383
2381
|
name: "hardcoded-value",
|
|
1384
|
-
appliesTo: (file) =>
|
|
2382
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1385
2383
|
run(source, file, ctx) {
|
|
1386
|
-
|
|
2384
|
+
const participates = isStyleHost(file) ? ctx.projectParticipates : participatesInTokenLayer(source, ctx.tokens);
|
|
2385
|
+
if (!participates) return [];
|
|
1387
2386
|
const findings = [];
|
|
1388
2387
|
for (const block of leafBlocks(source)) {
|
|
1389
2388
|
if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
|
|
@@ -1433,6 +2432,20 @@ var hardcodedValue = {
|
|
|
1433
2432
|
}
|
|
1434
2433
|
}
|
|
1435
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
|
+
}
|
|
1436
2449
|
return findings;
|
|
1437
2450
|
}
|
|
1438
2451
|
};
|
|
@@ -1446,7 +2459,7 @@ var MIN_LIGHTNESS = 8;
|
|
|
1446
2459
|
var MAX_LIGHTNESS = 92;
|
|
1447
2460
|
var violetBandHue = {
|
|
1448
2461
|
name: "violet-band-hue",
|
|
1449
|
-
appliesTo: (file) =>
|
|
2462
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1450
2463
|
run(source, file, ctx) {
|
|
1451
2464
|
const findings = [];
|
|
1452
2465
|
const starts = buildLineIndex(source);
|
|
@@ -1489,7 +2502,7 @@ function getDetector(name) {
|
|
|
1489
2502
|
}
|
|
1490
2503
|
|
|
1491
2504
|
// src/check/run.ts
|
|
1492
|
-
function runChecks(projectRoot, files, index, tokens, bucketFilter) {
|
|
2505
|
+
function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false) {
|
|
1493
2506
|
const findings = [];
|
|
1494
2507
|
const active = index.filter(
|
|
1495
2508
|
(e) => !!e.detector && !!getDetector(e.detector)
|
|
@@ -1501,13 +2514,13 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter) {
|
|
|
1501
2514
|
if (applicable.length === 0) continue;
|
|
1502
2515
|
let raw;
|
|
1503
2516
|
try {
|
|
1504
|
-
raw =
|
|
2517
|
+
raw = readFileSync9(join14(projectRoot, file), "utf8");
|
|
1505
2518
|
} catch {
|
|
1506
2519
|
continue;
|
|
1507
2520
|
}
|
|
1508
|
-
const source = maskComments(raw);
|
|
2521
|
+
const source = maskComments(maskNonStyleRegions(raw, file));
|
|
1509
2522
|
for (const { entry, detector } of applicable) {
|
|
1510
|
-
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens };
|
|
2523
|
+
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw };
|
|
1511
2524
|
findings.push(...detector.run(source, file, ctx));
|
|
1512
2525
|
}
|
|
1513
2526
|
}
|
|
@@ -1515,15 +2528,15 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter) {
|
|
|
1515
2528
|
}
|
|
1516
2529
|
|
|
1517
2530
|
// src/check/tokens.ts
|
|
1518
|
-
import { existsSync as
|
|
1519
|
-
import { join as
|
|
1520
|
-
function loadTokenMap(
|
|
1521
|
-
const tokensDir =
|
|
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");
|
|
1522
2535
|
const tokens = {};
|
|
1523
|
-
if (!
|
|
1524
|
-
const files =
|
|
2536
|
+
if (!existsSync8(tokensDir)) return tokens;
|
|
2537
|
+
const files = readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort();
|
|
1525
2538
|
for (const file of files) {
|
|
1526
|
-
const source =
|
|
2539
|
+
const source = readFileSync10(join15(tokensDir, file), "utf8");
|
|
1527
2540
|
for (const block of splitRuleBlocks(source)) {
|
|
1528
2541
|
if (block.body.includes("{")) continue;
|
|
1529
2542
|
if (block.selector.trim() !== ":root") continue;
|
|
@@ -1538,17 +2551,53 @@ function loadTokenMap(installRoot) {
|
|
|
1538
2551
|
}
|
|
1539
2552
|
|
|
1540
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
|
+
}
|
|
1541
2584
|
function check(opts) {
|
|
1542
|
-
const
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
const
|
|
1547
|
-
const index = validateIndex(JSON.parse(readFileSync7(indexPath, "utf8")));
|
|
1548
|
-
const tokens = loadTokenMap(target.installRoot);
|
|
1549
|
-
const { files } = selectFiles(opts.projectRoot, opts.all);
|
|
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;
|
|
1550
2590
|
const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
|
|
1551
|
-
const
|
|
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);
|
|
1552
2601
|
const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
|
|
1553
2602
|
const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
|
|
1554
2603
|
const noTokenLayer = (
|
|
@@ -1556,7 +2605,7 @@ function check(opts) {
|
|
|
1556
2605
|
// told to add a token @import it has nowhere to put.
|
|
1557
2606
|
cssFiles.length > 0 && cssFiles.every((f) => {
|
|
1558
2607
|
try {
|
|
1559
|
-
return !participatesInTokenLayer(
|
|
2608
|
+
return !participatesInTokenLayer(readFileSync11(join16(opts.projectRoot, f), "utf8"), tokens);
|
|
1560
2609
|
} catch {
|
|
1561
2610
|
return true;
|
|
1562
2611
|
}
|
|
@@ -1564,19 +2613,25 @@ function check(opts) {
|
|
|
1564
2613
|
);
|
|
1565
2614
|
const report = formatReport(findings, {
|
|
1566
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(),
|
|
1567
2620
|
version: opts.version,
|
|
1568
|
-
noTokenLayer
|
|
2621
|
+
noTokenLayer,
|
|
2622
|
+
mode: resolveMode(opts.projectRoot),
|
|
2623
|
+
unscanned: summariseUnscanned(files)
|
|
1569
2624
|
});
|
|
1570
2625
|
return { findings, report, hasError };
|
|
1571
2626
|
}
|
|
1572
2627
|
|
|
1573
2628
|
// src/commands/init.ts
|
|
1574
|
-
import { existsSync as
|
|
1575
|
-
import { dirname as
|
|
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";
|
|
1576
2631
|
|
|
1577
2632
|
// src/init/detect.ts
|
|
1578
|
-
import { readFileSync as
|
|
1579
|
-
import { join as
|
|
2633
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
2634
|
+
import { join as join17 } from "path";
|
|
1580
2635
|
var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
|
|
1581
2636
|
var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
|
|
1582
2637
|
var TAILWIND_V4_THEME_RE = /@theme\b/;
|
|
@@ -1593,7 +2648,7 @@ var FRAMEWORK_DEPS = [
|
|
|
1593
2648
|
function detectFramework(projectRoot) {
|
|
1594
2649
|
let pkg;
|
|
1595
2650
|
try {
|
|
1596
|
-
pkg = JSON.parse(
|
|
2651
|
+
pkg = JSON.parse(readFileSync12(join17(projectRoot, "package.json"), "utf8"));
|
|
1597
2652
|
} catch {
|
|
1598
2653
|
return void 0;
|
|
1599
2654
|
}
|
|
@@ -1611,7 +2666,7 @@ function detect(projectRoot) {
|
|
|
1611
2666
|
for (const f of cssFiles) {
|
|
1612
2667
|
let source;
|
|
1613
2668
|
try {
|
|
1614
|
-
source =
|
|
2669
|
+
source = readFileSync12(join17(projectRoot, f), "utf8");
|
|
1615
2670
|
} catch {
|
|
1616
2671
|
continue;
|
|
1617
2672
|
}
|
|
@@ -1628,9 +2683,26 @@ function detect(projectRoot) {
|
|
|
1628
2683
|
return { cssSystem, cssFiles, tailwindConfigFile, tailwindV4EntryFile, framework: detectFramework(projectRoot) };
|
|
1629
2684
|
}
|
|
1630
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
|
+
|
|
1631
2703
|
// src/init/derive.ts
|
|
1632
|
-
import { readFileSync as
|
|
1633
|
-
import { join as
|
|
2704
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
2705
|
+
import { join as join18 } from "path";
|
|
1634
2706
|
var DEFAULT_PROPOSAL = {
|
|
1635
2707
|
h: 264,
|
|
1636
2708
|
s: 0,
|
|
@@ -1663,7 +2735,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
|
|
|
1663
2735
|
for (const file of cssFiles) {
|
|
1664
2736
|
let source;
|
|
1665
2737
|
try {
|
|
1666
|
-
source =
|
|
2738
|
+
source = readFileSync13(join18(projectRoot, file), "utf8");
|
|
1667
2739
|
} catch {
|
|
1668
2740
|
continue;
|
|
1669
2741
|
}
|
|
@@ -1712,7 +2784,7 @@ function balancedBraceBody(source, openIdx) {
|
|
|
1712
2784
|
function fromTailwindConfig(projectRoot, tailwindConfigFile) {
|
|
1713
2785
|
let source;
|
|
1714
2786
|
try {
|
|
1715
|
-
source =
|
|
2787
|
+
source = readFileSync13(join18(projectRoot, tailwindConfigFile), "utf8");
|
|
1716
2788
|
} catch {
|
|
1717
2789
|
return null;
|
|
1718
2790
|
}
|
|
@@ -1768,7 +2840,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
|
|
|
1768
2840
|
for (const file of cssFiles) {
|
|
1769
2841
|
let source;
|
|
1770
2842
|
try {
|
|
1771
|
-
source =
|
|
2843
|
+
source = readFileSync13(join18(projectRoot, file), "utf8");
|
|
1772
2844
|
} catch {
|
|
1773
2845
|
continue;
|
|
1774
2846
|
}
|
|
@@ -1818,6 +2890,20 @@ function worstRatio(h, s, l) {
|
|
|
1818
2890
|
const vsFill = contrastRatio(rgb, FILL_COMPOSITED);
|
|
1819
2891
|
return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
|
|
1820
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
|
+
}
|
|
1821
2907
|
function nearestPassingLightness(h, s, l) {
|
|
1822
2908
|
if (worstRatio(h, s, l).worst >= CONTRAST_FLOOR) return l;
|
|
1823
2909
|
for (let delta = 1; delta <= 100; delta++) {
|
|
@@ -1847,11 +2933,19 @@ function systemCollision(h, s, l) {
|
|
|
1847
2933
|
return null;
|
|
1848
2934
|
}
|
|
1849
2935
|
function validateBrandColor(h, s, l) {
|
|
1850
|
-
const
|
|
2936
|
+
const light = worstRatio(h, s, l);
|
|
2937
|
+
const dark = darkWorstRatio(h, s);
|
|
2938
|
+
const worst = Math.min(light.worst, dark.worst);
|
|
1851
2939
|
const passesContrast = worst >= CONTRAST_FLOOR;
|
|
1852
2940
|
return {
|
|
1853
|
-
ratioVsRaised: vsRaised,
|
|
1854
|
-
ratioVsFill: vsFill,
|
|
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,
|
|
1855
2949
|
worstRatio: worst,
|
|
1856
2950
|
passesContrast,
|
|
1857
2951
|
nearestPassingLightness: passesContrast ? void 0 : nearestPassingLightness(h, s, l) ?? void 0,
|
|
@@ -1868,7 +2962,7 @@ function round2(n) {
|
|
|
1868
2962
|
}
|
|
1869
2963
|
function renderBrandFile(defaultCssRaw, projectSlug, version2, proposal) {
|
|
1870
2964
|
const fileName = `brand.${projectSlug}.css`;
|
|
1871
|
-
const header = vendorHeader(fileName, version2, "css");
|
|
2965
|
+
const header = vendorHeader(fileName, version2, "css", null);
|
|
1872
2966
|
if (!defaultCssRaw.includes(TOP_COMMENT_FIRST_LINE) || !defaultCssRaw.includes(TOP_COMMENT_LAST_LINE)) {
|
|
1873
2967
|
throw new Error(
|
|
1874
2968
|
`brand-file: tokens/brand.default.css does not match the expected shape \u2014 ${TOP_COMMENT_FIRST_LINE} / ${TOP_COMMENT_LAST_LINE} not found.`
|
|
@@ -1906,8 +3000,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
|
|
|
1906
3000
|
}
|
|
1907
3001
|
|
|
1908
3002
|
// src/init/project-name.ts
|
|
1909
|
-
import { readFileSync as
|
|
1910
|
-
import { basename
|
|
3003
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
3004
|
+
import { basename, join as join19 } from "path";
|
|
1911
3005
|
function slugify(name) {
|
|
1912
3006
|
const withoutScope = name.replace(/^@[^/]+\//, "");
|
|
1913
3007
|
const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -1915,11 +3009,11 @@ function slugify(name) {
|
|
|
1915
3009
|
}
|
|
1916
3010
|
function deriveProjectSlug(projectRoot) {
|
|
1917
3011
|
try {
|
|
1918
|
-
const pkg = JSON.parse(
|
|
3012
|
+
const pkg = JSON.parse(readFileSync14(join19(projectRoot, "package.json"), "utf8"));
|
|
1919
3013
|
if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
|
|
1920
3014
|
} catch {
|
|
1921
3015
|
}
|
|
1922
|
-
return slugify(
|
|
3016
|
+
return slugify(basename(projectRoot));
|
|
1923
3017
|
}
|
|
1924
3018
|
|
|
1925
3019
|
// src/commands/init.ts
|
|
@@ -1939,13 +3033,15 @@ function validateRounded(p) {
|
|
|
1939
3033
|
}
|
|
1940
3034
|
function describeValidation(v) {
|
|
1941
3035
|
const lines = [];
|
|
3036
|
+
const modes = `light ${v.lightWorstRatio.toFixed(2)}:1, dark ${v.darkWorstRatio.toFixed(2)}:1`;
|
|
1942
3037
|
if (v.passesContrast) {
|
|
1943
3038
|
lines.push(
|
|
1944
|
-
` contrast OK \u2014 ${v.ratioVsRaised.toFixed(2)}
|
|
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)`
|
|
1945
3040
|
);
|
|
1946
3041
|
} else {
|
|
3042
|
+
const failing = v.lightWorstRatio < v.darkWorstRatio ? "light" : "dark";
|
|
1947
3043
|
lines.push(
|
|
1948
|
-
` contrast FAILS \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${
|
|
3044
|
+
` contrast FAILS in ${failing} mode \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${modes})`
|
|
1949
3045
|
);
|
|
1950
3046
|
if (v.nearestPassingLightness !== void 0) {
|
|
1951
3047
|
lines.push(` nearest passing lightness at the same hue/saturation: ${v.nearestPassingLightness}%`);
|
|
@@ -1979,7 +3075,7 @@ function parseSurfaces(answer, fallback) {
|
|
|
1979
3075
|
return parsed.length > 0 ? parsed : fallback;
|
|
1980
3076
|
}
|
|
1981
3077
|
function fileState(projectRoot, absPath, relPath, initManifest) {
|
|
1982
|
-
const existsOnDisk =
|
|
3078
|
+
const existsOnDisk = existsSync10(absPath);
|
|
1983
3079
|
const tracked = !!initManifest && relPath in initManifest.files;
|
|
1984
3080
|
const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
|
|
1985
3081
|
return { existsOnDisk, tracked, modified };
|
|
@@ -1990,11 +3086,11 @@ function isValidSurfaceArray(v) {
|
|
|
1990
3086
|
);
|
|
1991
3087
|
}
|
|
1992
3088
|
function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
|
|
1993
|
-
if (!
|
|
3089
|
+
if (!existsSync10(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
1994
3090
|
try {
|
|
1995
|
-
const parsed = JSON.parse(
|
|
3091
|
+
const parsed = JSON.parse(readFileSync15(configAbsPath, "utf8"));
|
|
1996
3092
|
const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
|
|
1997
|
-
const brandResolves = brandCandidate ?
|
|
3093
|
+
const brandResolves = brandCandidate ? existsSync10(join20(projectRoot, ...brandCandidate.split("/"))) : false;
|
|
1998
3094
|
return {
|
|
1999
3095
|
brand: brandResolves ? brandCandidate : fallbackBrand,
|
|
2000
3096
|
surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
|
|
@@ -2020,7 +3116,7 @@ function insertAfterCharset(content, insertion) {
|
|
|
2020
3116
|
return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
|
|
2021
3117
|
}
|
|
2022
3118
|
function wireImport(absPath, brandImport, modeImport, mode) {
|
|
2023
|
-
const content =
|
|
3119
|
+
const content = existsSync10(absPath) ? readFileSync15(absPath, "utf8") : "";
|
|
2024
3120
|
const brandImportLine = `@import "${brandImport}";`;
|
|
2025
3121
|
const modeImportLine = `@import "${modeImport}";`;
|
|
2026
3122
|
const brandPresent = content.includes(brandImportLine);
|
|
@@ -2033,7 +3129,7 @@ function wireImport(absPath, brandImport, modeImport, mode) {
|
|
|
2033
3129
|
const previousMode = existingModeMatch[2];
|
|
2034
3130
|
const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
|
|
2035
3131
|
` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
|
|
2036
|
-
|
|
3132
|
+
writeFileSync6(absPath, next3, "utf8");
|
|
2037
3133
|
return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
|
|
2038
3134
|
}
|
|
2039
3135
|
const brandIdx = content.indexOf(brandImportLine);
|
|
@@ -2041,7 +3137,7 @@ function wireImport(absPath, brandImport, modeImport, mode) {
|
|
|
2041
3137
|
if (content[insertAt] === "\r") insertAt++;
|
|
2042
3138
|
if (content[insertAt] === "\n") insertAt++;
|
|
2043
3139
|
const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
|
|
2044
|
-
|
|
3140
|
+
writeFileSync6(absPath, next2, "utf8");
|
|
2045
3141
|
return { status: "rewired", detail: `added missing mode import: ${mode}` };
|
|
2046
3142
|
}
|
|
2047
3143
|
const lines = `${brandImportLine}
|
|
@@ -2056,17 +3152,32 @@ ${modeImportLine}
|
|
|
2056
3152
|
next = insertAfterCharset(content, content ? `${lines}
|
|
2057
3153
|
` : lines);
|
|
2058
3154
|
}
|
|
2059
|
-
|
|
2060
|
-
|
|
3155
|
+
mkdirSync6(dirname7(absPath), { recursive: true });
|
|
3156
|
+
writeFileSync6(absPath, next, "utf8");
|
|
2061
3157
|
return { status: "wired" };
|
|
2062
3158
|
}
|
|
2063
3159
|
async function init(opts) {
|
|
2064
3160
|
const log = opts.log ?? ((line) => console.log(line));
|
|
2065
3161
|
const prompt = opts.prompt ?? defaultPrompt;
|
|
2066
|
-
const
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
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);
|
|
2070
3181
|
const detection = detect(opts.projectRoot);
|
|
2071
3182
|
log(`Detected: ${detection.cssSystem}${detection.framework ? ` (${detection.framework})` : ""}`);
|
|
2072
3183
|
if (detection.cssFiles.length === 0) {
|
|
@@ -2107,6 +3218,11 @@ async function init(opts) {
|
|
|
2107
3218
|
);
|
|
2108
3219
|
}
|
|
2109
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
|
+
}
|
|
2110
3226
|
if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
|
|
2111
3227
|
const originalL = proposal.l;
|
|
2112
3228
|
proposal = { ...proposal, l: validation.nearestPassingLightness };
|
|
@@ -2119,9 +3235,9 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
|
|
|
2119
3235
|
const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
|
|
2120
3236
|
const projectSlug = deriveProjectSlug(opts.projectRoot);
|
|
2121
3237
|
const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
|
|
2122
|
-
const brandAbsPath =
|
|
3238
|
+
const brandAbsPath = join20(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
|
|
2123
3239
|
const configRelPath = "jig.config.json";
|
|
2124
|
-
const configAbsPath =
|
|
3240
|
+
const configAbsPath = join20(opts.projectRoot, configRelPath);
|
|
2125
3241
|
const initManifest = readInitManifest(opts.projectRoot);
|
|
2126
3242
|
const files = { ...initManifest?.files ?? {} };
|
|
2127
3243
|
const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
|
|
@@ -2150,68 +3266,72 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
2150
3266
|
if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
|
|
2151
3267
|
}
|
|
2152
3268
|
if (brandAction === "written") {
|
|
2153
|
-
const defaultCssRaw =
|
|
3269
|
+
const defaultCssRaw = readFileSync15(join20(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
|
|
2154
3270
|
const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
|
|
2155
|
-
|
|
2156
|
-
|
|
3271
|
+
mkdirSync6(dirname7(brandAbsPath), { recursive: true });
|
|
3272
|
+
writeFileSync6(brandAbsPath, content, "utf8");
|
|
2157
3273
|
files[brandRelPath] = checksum(content);
|
|
2158
3274
|
}
|
|
2159
3275
|
if (configAction === "written") {
|
|
2160
3276
|
const config = { brand: brandRelPath, surfaces };
|
|
2161
3277
|
const content = `${JSON.stringify(config, null, 2)}
|
|
2162
3278
|
`;
|
|
2163
|
-
|
|
3279
|
+
writeFileSync6(configAbsPath, content, "utf8");
|
|
2164
3280
|
files[configRelPath] = checksum(content);
|
|
2165
3281
|
} else if (configAction === "merged") {
|
|
2166
3282
|
let existing = {};
|
|
2167
3283
|
try {
|
|
2168
|
-
existing = JSON.parse(
|
|
3284
|
+
existing = JSON.parse(readFileSync15(configAbsPath, "utf8"));
|
|
2169
3285
|
} catch {
|
|
2170
3286
|
existing = {};
|
|
2171
3287
|
}
|
|
2172
3288
|
const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
|
|
2173
|
-
const brandStillResolves = existingBrand ?
|
|
3289
|
+
const brandStillResolves = existingBrand ? existsSync10(join20(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
2174
3290
|
const merged = {
|
|
2175
3291
|
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
2176
3292
|
surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
|
|
2177
3293
|
};
|
|
2178
3294
|
const content = `${JSON.stringify(merged, null, 2)}
|
|
2179
3295
|
`;
|
|
2180
|
-
|
|
3296
|
+
writeFileSync6(configAbsPath, content, "utf8");
|
|
2181
3297
|
files[configRelPath] = checksum(content);
|
|
2182
3298
|
}
|
|
2183
3299
|
const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
|
|
2184
3300
|
const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
|
|
2185
|
-
const wiringBrandAbsPath =
|
|
2186
|
-
const
|
|
2187
|
-
const
|
|
2188
|
-
|
|
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;
|
|
2189
3308
|
const modeRelPath = relKey(".jig", "tokens", modeFileName);
|
|
2190
|
-
const modeState = fileState(opts.projectRoot,
|
|
3309
|
+
const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
|
|
2191
3310
|
let modeAction = "written";
|
|
2192
3311
|
if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
|
|
2193
3312
|
else if (modeState.modified) modeAction = "skipped-edited";
|
|
2194
3313
|
if (modeAction === "written") {
|
|
2195
|
-
const modeSourceRaw =
|
|
2196
|
-
const content = vendorHeader(modeFileName, opts.version, "css") + modeSourceRaw;
|
|
2197
|
-
|
|
2198
|
-
|
|
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");
|
|
2199
3318
|
files[modeRelPath] = checksum(content);
|
|
2200
3319
|
} else {
|
|
2201
3320
|
log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
|
|
2202
3321
|
}
|
|
2203
3322
|
}
|
|
2204
|
-
|
|
3323
|
+
const modeAbsPath = modeAbsPaths[primaryMode];
|
|
3324
|
+
writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
|
|
2205
3325
|
const wireTarget = findWireTarget(detection);
|
|
2206
3326
|
let wiring;
|
|
2207
3327
|
if (wireTarget) {
|
|
2208
|
-
const targetAbsDir =
|
|
3328
|
+
const targetAbsDir = dirname7(join20(opts.projectRoot, wireTarget));
|
|
2209
3329
|
const brandImport = relativeImportPath(targetAbsDir, wiringBrandAbsPath);
|
|
2210
3330
|
const modeImport = relativeImportPath(targetAbsDir, modeAbsPath);
|
|
2211
3331
|
const snippet = `@import "${brandImport}";
|
|
2212
3332
|
@import "${modeImport}";`;
|
|
2213
3333
|
try {
|
|
2214
|
-
const outcome = wireImport(
|
|
3334
|
+
const outcome = wireImport(join20(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
|
|
2215
3335
|
wiring = { target: wireTarget, status: outcome.status, snippet };
|
|
2216
3336
|
const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
|
|
2217
3337
|
log(`
|
|
@@ -2231,11 +3351,29 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
2231
3351
|
const snippet = `@import "${brandImport}";
|
|
2232
3352
|
@import "${modeImport}";`;
|
|
2233
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")) {
|
|
2234
3374
|
log(
|
|
2235
|
-
|
|
2236
|
-
Could not find a single unambiguous stylesheet to wire the import into. Add this near the top of your global stylesheet (paths shown relative to the project root \u2014 adjust to wherever you paste them):`
|
|
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."
|
|
2237
3376
|
);
|
|
2238
|
-
log(` ${snippet.split("\n").join("\n ")}`);
|
|
2239
3377
|
}
|
|
2240
3378
|
const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
|
|
2241
3379
|
log("\nBaseline:");
|
|
@@ -2259,7 +3397,13 @@ Could not find a single unambiguous stylesheet to wire the import into. Add this
|
|
|
2259
3397
|
|
|
2260
3398
|
// src/index.ts
|
|
2261
3399
|
var packageRoot = getPackageRoot();
|
|
2262
|
-
var { version } = JSON.parse(
|
|
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
|
+
}
|
|
2263
3407
|
var program = new Command();
|
|
2264
3408
|
program.name("jig").description("A design system for coding agents.").version(version);
|
|
2265
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) => {
|
|
@@ -2277,7 +3421,12 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
2277
3421
|
version,
|
|
2278
3422
|
homeDir: homedir()
|
|
2279
3423
|
});
|
|
3424
|
+
if (result.warning) {
|
|
3425
|
+
console.warn(result.warning);
|
|
3426
|
+
return;
|
|
3427
|
+
}
|
|
2280
3428
|
console.log(`Installed Jig v${version} for ${opts.agent} (${opts.scope} scope)`);
|
|
3429
|
+
warnIfUnpublishedPin();
|
|
2281
3430
|
for (const f of result.written) console.log(` + ${f}`);
|
|
2282
3431
|
for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
|
|
2283
3432
|
} catch (err) {
|
|
@@ -2285,6 +3434,14 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
2285
3434
|
process.exit(1);
|
|
2286
3435
|
}
|
|
2287
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
|
+
});
|
|
2288
3445
|
program.command("update").description("Update vendored Jig rules, skipping files you have edited.").action(() => {
|
|
2289
3446
|
const projectRoot = findProjectRoot(process.cwd());
|
|
2290
3447
|
try {
|
|
@@ -2296,7 +3453,11 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
2296
3453
|
version,
|
|
2297
3454
|
homeDir: homedir()
|
|
2298
3455
|
});
|
|
2299
|
-
|
|
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
|
+
);
|
|
2300
3461
|
for (const f of result.updated) console.log(` ~ ${f}`);
|
|
2301
3462
|
for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
|
|
2302
3463
|
} catch (err) {
|