td-ai-tools 1.3.2 → 1.3.4
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/bin/cli.js +59 -8
- package/lib/gutter.js +89 -0
- package/lib/installer.js +30 -8
- package/package.json +1 -1
- package/skills/shopify-lint/SKILL.md +2 -2
- package/skills/shopify-lint/eslint-plugin-theory/package-lock.json +3 -3
- package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.test.ts +36 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.ts +16 -9
- package/skills/shopify-lint/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/scripts/shopify_lint.py +39 -8
- package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.test.ts +39 -0
- package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.ts +15 -8
- package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/tests/test_shopify_lint.py +68 -13
- package/skills/shopify-lint/theme-check-theory/README.md +7 -0
- package/skills/shopify-lint/theme-check-theory/package-lock.json +4 -4
- package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.test.ts +60 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.ts +9 -0
package/bin/cli.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* command dispatch. All filesystem work lives in `../lib/`.
|
|
5
5
|
*/
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
8
9
|
import * as p from '@clack/prompts';
|
|
9
10
|
import pc from 'picocolors';
|
|
@@ -14,6 +15,7 @@ import { readFrontmatterField } from '../lib/frontmatter.js';
|
|
|
14
15
|
import {
|
|
15
16
|
installSkill, installAgent, deleteSkill, deleteAgent, skillHasSetup,
|
|
16
17
|
} from '../lib/installer.js';
|
|
18
|
+
import { createGutterWriter } from '../lib/gutter.js';
|
|
17
19
|
|
|
18
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
21
|
const __dirname = path.dirname(__filename);
|
|
@@ -32,6 +34,14 @@ const { skillsDir: SKILLS_DIR, agentsDir: AGENTS_DIR } = ctx;
|
|
|
32
34
|
|
|
33
35
|
const IS_TTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
34
36
|
|
|
37
|
+
/** Matches `@clack/prompts`' own unicode fallback so gutters line up everywhere. */
|
|
38
|
+
const IS_UNICODE = process.platform !== 'win32'
|
|
39
|
+
|| Boolean(process.env.WT_SESSION)
|
|
40
|
+
|| process.env.TERM_PROGRAM === 'vscode';
|
|
41
|
+
|
|
42
|
+
/** The vertical gutter `@clack/prompts` draws between log lines. */
|
|
43
|
+
const BAR = IS_UNICODE ? '│' : '|';
|
|
44
|
+
|
|
35
45
|
/**
|
|
36
46
|
* @typedef {import('../lib/catalog.js').ItemType} ItemType
|
|
37
47
|
* @typedef {import('../lib/catalog.js').Ctx} Ctx
|
|
@@ -66,6 +76,43 @@ function status(kind, msg) {
|
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Runs a skill setup command. Under a TTY the child's output is streamed through
|
|
81
|
+
* a gutter writer so multi-stage setups (npm installs, package builds) stay
|
|
82
|
+
* inside the CLI's log flow, with stderr folded into the same stream; elsewhere
|
|
83
|
+
* stdio is inherited unchanged so logs and CI see the raw output.
|
|
84
|
+
*
|
|
85
|
+
* @type {import('../lib/installer.js').SetupRunner}
|
|
86
|
+
*/
|
|
87
|
+
function runSetupCommand({ command, args, cwd }) {
|
|
88
|
+
if (!IS_TTY) {
|
|
89
|
+
const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: false });
|
|
90
|
+
return Promise.resolve({ status: result.status, error: result.error });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return new Promise(resolve => {
|
|
94
|
+
// One writer for both streams: a shared line buffer keeps stdout and stderr
|
|
95
|
+
// in the order the child produced them and stops half-written lines from
|
|
96
|
+
// interleaving through the gutter.
|
|
97
|
+
const log = createGutterWriter({ stream: process.stdout, bar: BAR });
|
|
98
|
+
const child = spawn(command, args, {
|
|
99
|
+
cwd,
|
|
100
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
101
|
+
shell: false,
|
|
102
|
+
});
|
|
103
|
+
child.stdout.setEncoding('utf8');
|
|
104
|
+
child.stderr.setEncoding('utf8');
|
|
105
|
+
child.stdout.on('data', chunk => log.write(chunk, 'stdout'));
|
|
106
|
+
child.stderr.on('data', chunk => log.write(chunk, 'stderr'));
|
|
107
|
+
const finish = result => {
|
|
108
|
+
log.end();
|
|
109
|
+
resolve(result);
|
|
110
|
+
};
|
|
111
|
+
child.on('error', error => finish({ status: null, error }));
|
|
112
|
+
child.on('close', status => finish({ status }));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
69
116
|
/**
|
|
70
117
|
* Display string for an installed item's version state, e.g.
|
|
71
118
|
* `"v1.0.0 → v1.2.0 ⬆ outdated"` or `"v1.2.0 (up to date)"`.
|
|
@@ -139,14 +186,16 @@ function hintFor(type, name) {
|
|
|
139
186
|
*
|
|
140
187
|
* @param {MenuItem[]} items - Items to install.
|
|
141
188
|
* @param {{replaceExisting?: boolean}} [options] - Forwarded to the installer.
|
|
142
|
-
* @returns {boolean} Whether every installer reported success.
|
|
189
|
+
* @returns {Promise<boolean>} Whether every installer reported success.
|
|
143
190
|
*/
|
|
144
|
-
function installItems(items, options = {}) {
|
|
191
|
+
async function installItems(items, options = {}) {
|
|
145
192
|
const { runSetup = false, ...installOptions } = options;
|
|
146
193
|
let ok = true;
|
|
147
194
|
for (const item of items) {
|
|
148
195
|
const installed = item.type === 'skill'
|
|
149
|
-
? installSkill(ctx, item.name, {
|
|
196
|
+
? await installSkill(ctx, item.name, {
|
|
197
|
+
...installOptions, runSetup, runner: runSetupCommand, report: status,
|
|
198
|
+
})
|
|
150
199
|
: installAgent(ctx, item.name, { ...installOptions, report: status });
|
|
151
200
|
if (!installed) ok = false;
|
|
152
201
|
}
|
|
@@ -447,7 +496,7 @@ async function interactiveInstall(mode = 'install') {
|
|
|
447
496
|
}
|
|
448
497
|
|
|
449
498
|
const runSetup = await confirmSetup(items);
|
|
450
|
-
const ok = installItems(items, { replaceExisting: mode === 'update', runSetup });
|
|
499
|
+
const ok = await installItems(items, { replaceExisting: mode === 'update', runSetup });
|
|
451
500
|
if (!ok) {
|
|
452
501
|
p.outro(pc.red(`${mode === 'update' ? 'Update' : 'Install'} completed with errors.`));
|
|
453
502
|
process.exitCode = 1;
|
|
@@ -644,9 +693,9 @@ async function main() {
|
|
|
644
693
|
if (cmd === 'install') {
|
|
645
694
|
const { rest, runSetup } = parseInstallFlags(args.slice(1));
|
|
646
695
|
if (rest[0] === '--all') {
|
|
647
|
-
if (!installItems(buildMenu(), { runSetup })) process.exitCode = 1;
|
|
696
|
+
if (!await installItems(buildMenu(), { runSetup })) process.exitCode = 1;
|
|
648
697
|
} else {
|
|
649
|
-
if (!installItems(resolveNames(rest), { runSetup })) process.exitCode = 1;
|
|
698
|
+
if (!await installItems(resolveNames(rest), { runSetup })) process.exitCode = 1;
|
|
650
699
|
}
|
|
651
700
|
return;
|
|
652
701
|
}
|
|
@@ -663,9 +712,11 @@ async function main() {
|
|
|
663
712
|
status('info', 'No installed skills or agent packs match the catalog.');
|
|
664
713
|
return;
|
|
665
714
|
}
|
|
666
|
-
if (!installItems(menu, { replaceExisting: true, runSetup })) process.exitCode = 1;
|
|
715
|
+
if (!await installItems(menu, { replaceExisting: true, runSetup })) process.exitCode = 1;
|
|
667
716
|
} else {
|
|
668
|
-
if (!installItems(resolveUpdateNames(rest), { replaceExisting: true, runSetup }))
|
|
717
|
+
if (!await installItems(resolveUpdateNames(rest), { replaceExisting: true, runSetup })) {
|
|
718
|
+
process.exitCode = 1;
|
|
719
|
+
}
|
|
669
720
|
}
|
|
670
721
|
return;
|
|
671
722
|
}
|
package/lib/gutter.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Renders streamed child-process output inside the `@clack/prompts` log
|
|
3
|
+
* gutter.
|
|
4
|
+
*
|
|
5
|
+
* Setup scripts (npm installs, package builds) emit long, unindented output that
|
|
6
|
+
* otherwise interrupts the CLI's vertical bar flow. This module reframes that
|
|
7
|
+
* output as subordinate detail: every line is prefixed with the gutter bar,
|
|
8
|
+
* dimmed, wrapped so long paths cannot escape the gutter, and runs of blank
|
|
9
|
+
* lines collapse to a single bar.
|
|
10
|
+
*/
|
|
11
|
+
import pc from 'picocolors';
|
|
12
|
+
|
|
13
|
+
/** Matches ANSI escape sequences, which would fight the dim styling. */
|
|
14
|
+
const ANSI = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wraps a line to a width, preferring a space break and hard-wrapping otherwise.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text - Single line of text (no newlines).
|
|
20
|
+
* @param {number} width - Maximum characters per returned line.
|
|
21
|
+
* @returns {string[]} One or more lines, none longer than `width`.
|
|
22
|
+
*/
|
|
23
|
+
export function wrapLine(text, width) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
let rest = text;
|
|
26
|
+
while (rest.length > width) {
|
|
27
|
+
const space = rest.lastIndexOf(' ', width);
|
|
28
|
+
const cut = space > width / 2 ? space : width;
|
|
29
|
+
lines.push(rest.slice(0, cut).trimEnd());
|
|
30
|
+
rest = rest.slice(cut).trimStart();
|
|
31
|
+
}
|
|
32
|
+
lines.push(rest);
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a line-buffered writer that emits guttered output.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {import('node:stream').Writable} options.stream - Destination stream.
|
|
41
|
+
* @param {string} options.bar - The gutter character (`'│'`, or `'|'` without unicode).
|
|
42
|
+
* @param {() => number} [options.columns] - Current terminal width; read per line so a mid-run resize is respected.
|
|
43
|
+
* @returns {{write: (chunk: string, channel?: string) => void, end: () => void}} `write` takes arbitrary chunks tagged with an optional source channel (so a partial line from one stream never absorbs the next line of another); `end` flushes a trailing partial line.
|
|
44
|
+
*/
|
|
45
|
+
export function createGutterWriter({ stream, bar, columns = () => process.stdout.columns || 80 }) {
|
|
46
|
+
const gutter = pc.gray(bar);
|
|
47
|
+
let pending = '';
|
|
48
|
+
let blank = false;
|
|
49
|
+
let lastChannel = '';
|
|
50
|
+
|
|
51
|
+
const emit = raw => {
|
|
52
|
+
// Keep only the last carriage-return segment so redrawn progress lines
|
|
53
|
+
// (npm, tsc) land as one line rather than stacking up.
|
|
54
|
+
const carriage = raw.lastIndexOf('\r');
|
|
55
|
+
const line = (carriage === -1 ? raw : raw.slice(carriage + 1))
|
|
56
|
+
.replace(ANSI, '')
|
|
57
|
+
.replace(/\s+$/, '');
|
|
58
|
+
|
|
59
|
+
if (!line) {
|
|
60
|
+
if (!blank) stream.write(`${gutter}\n`);
|
|
61
|
+
blank = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
blank = false;
|
|
65
|
+
|
|
66
|
+
const width = Math.max(24, columns() - 6);
|
|
67
|
+
const [first, ...continued] = wrapLine(line, width);
|
|
68
|
+
stream.write(`${gutter} ${pc.dim(first)}\n`);
|
|
69
|
+
for (const cont of continued) stream.write(`${gutter} ${pc.dim(cont)}\n`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
write(chunk, channel = '') {
|
|
74
|
+
if (channel !== lastChannel && pending) {
|
|
75
|
+
emit(pending);
|
|
76
|
+
pending = '';
|
|
77
|
+
}
|
|
78
|
+
lastChannel = channel;
|
|
79
|
+
pending += chunk;
|
|
80
|
+
const parts = pending.split('\n');
|
|
81
|
+
pending = parts.pop() ?? '';
|
|
82
|
+
for (const part of parts) emit(part);
|
|
83
|
+
},
|
|
84
|
+
end() {
|
|
85
|
+
if (pending) emit(pending);
|
|
86
|
+
pending = '';
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/lib/installer.js
CHANGED
|
@@ -30,6 +30,21 @@ import {
|
|
|
30
30
|
/** @type {ReportFn} */
|
|
31
31
|
const noop = () => {};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Runs a skill's setup command and resolves with its exit result. The CLI passes
|
|
35
|
+
* a presentation-aware runner that keeps child output inside its log gutter;
|
|
36
|
+
* the default simply inherits this process's stdio.
|
|
37
|
+
* @callback SetupRunner
|
|
38
|
+
* @param {{command: string, args: string[], cwd: string}} spec - Command to run and the directory to run it in.
|
|
39
|
+
* @returns {Promise<{status: (number|null), error?: Error}>} Exit status, or a spawn `error`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** @type {SetupRunner} */
|
|
43
|
+
const inheritStdioRunner = async ({ command, args, cwd }) => {
|
|
44
|
+
const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: false });
|
|
45
|
+
return { status: result.status, error: result.error };
|
|
46
|
+
};
|
|
47
|
+
|
|
33
48
|
/**
|
|
34
49
|
* Recognized setup commands, in priority order, relative to an installed skill.
|
|
35
50
|
* @type {{file: string, command: string, args: string[]}[]}
|
|
@@ -83,16 +98,17 @@ export function skillHasSetup(ctx, name) {
|
|
|
83
98
|
* @param {string} dest - Installed skill directory.
|
|
84
99
|
* @param {string} projectRoot - Root of the project receiving the skill.
|
|
85
100
|
* @param {ReportFn} report
|
|
86
|
-
* @
|
|
101
|
+
* @param {SetupRunner} runner - Executes the setup command.
|
|
102
|
+
* @returns {Promise<boolean>} Whether setup succeeded or no setup existed.
|
|
87
103
|
*/
|
|
88
|
-
function runSkillSetup(name, target, dest, projectRoot, report) {
|
|
104
|
+
async function runSkillSetup(name, target, dest, projectRoot, report, runner) {
|
|
89
105
|
const setup = findSkillSetup(dest);
|
|
90
106
|
if (setup) {
|
|
91
107
|
report('step', `setup: ${name} running ${setup.command} ${setup.args.join(' ')} in ${target.root}/skills/${name}/`);
|
|
92
|
-
const result =
|
|
108
|
+
const result = await runner({
|
|
109
|
+
command: setup.command,
|
|
110
|
+
args: setup.args,
|
|
93
111
|
cwd: dest,
|
|
94
|
-
stdio: 'inherit',
|
|
95
|
-
shell: false,
|
|
96
112
|
});
|
|
97
113
|
if (result.error) {
|
|
98
114
|
report('error', `setup: ${name} failed in ${target.root}/skills/${name}/: ${result.error.message}`);
|
|
@@ -144,10 +160,16 @@ function registerBundledAgents(ctx, target, srcDir, report) {
|
|
|
144
160
|
* @param {object} [options]
|
|
145
161
|
* @param {boolean} [options.replaceExisting=false] - Overwrite an existing install (used by `update`).
|
|
146
162
|
* @param {boolean} [options.runSetup=false] - Run recognized setup scripts after each target copy.
|
|
163
|
+
* @param {SetupRunner} [options.runner] - Executes setup commands; defaults to inheriting stdio.
|
|
147
164
|
* @param {ReportFn} [options.report] - Progress sink.
|
|
148
|
-
* @returns {boolean} `false` if the skill is not in the catalog or setup fails, otherwise `true`.
|
|
165
|
+
* @returns {Promise<boolean>} `false` if the skill is not in the catalog or setup fails, otherwise `true`.
|
|
149
166
|
*/
|
|
150
|
-
export function installSkill(ctx, name, {
|
|
167
|
+
export async function installSkill(ctx, name, {
|
|
168
|
+
replaceExisting = false,
|
|
169
|
+
runSetup = false,
|
|
170
|
+
runner = inheritStdioRunner,
|
|
171
|
+
report = noop,
|
|
172
|
+
} = {}) {
|
|
151
173
|
let ok = true;
|
|
152
174
|
const src = path.join(ctx.skillsDir, name);
|
|
153
175
|
if (!fs.existsSync(src)) {
|
|
@@ -164,7 +186,7 @@ export function installSkill(ctx, name, { replaceExisting = false, runSetup = fa
|
|
|
164
186
|
copyDir(src, dest);
|
|
165
187
|
report('success', `skill: ${name} ${replaceExisting ? 'updated' : 'installed'} → ${target.root}/skills/${name}/`);
|
|
166
188
|
registerBundledAgents(ctx, target, src, report);
|
|
167
|
-
if (runSetup && !runSkillSetup(name, target, dest, ctx.targetRoot, report)) ok = false;
|
|
189
|
+
if (runSetup && !(await runSkillSetup(name, target, dest, ctx.targetRoot, report, runner))) ok = false;
|
|
168
190
|
}
|
|
169
191
|
return ok;
|
|
170
192
|
}
|
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@ python3 .agents/skills/shopify-lint/scripts/shopify_lint.py --path .
|
|
|
40
40
|
|
|
41
41
|
The script identifies files changed since the current branch's merge-base, adds staged, unstaged, and untracked files, then runs three linters over that set and merges their results into one report:
|
|
42
42
|
|
|
43
|
-
- **Theme Check** — `shopify theme check --output json`. The root `.theme-check.yml` directly requires the bundled `theme-check-theory` package from this skill directory. `DisallowedScriptOrStyleTag` reports inline executable `<script>` and all `<style>` tags in modified Liquid files while allowing external-source and JSON-data scripts. `HardcodedText` reports rendered hard-coded copy, except content inside Liquid `stylesheet`, `javascript`, and `schema` tags. The generated `.theme-check.yml` also pins the upstream `StaticStylesheetAndJavascriptTags` to `error`, so Liquid written inside a `{% stylesheet %}` or `{% javascript %}` block always fails the lint.
|
|
43
|
+
- **Theme Check** — `shopify theme check --output json`. The root `.theme-check.yml` directly requires the bundled `theme-check-theory` package from this skill directory. `DisallowedScriptOrStyleTag` reports inline executable `<script>` and all `<style>` tags in modified Liquid files while allowing external-source and JSON-data scripts. `HardcodedText` reports rendered hard-coded copy, except content inside Liquid `stylesheet`, `javascript`, and `schema` tags, and except text beginning with `--`, which is a CSS custom property name rather than copy. The generated `.theme-check.yml` also pins the upstream `StaticStylesheetAndJavascriptTags` to `error`, so Liquid written inside a `{% stylesheet %}` or `{% javascript %}` block always fails the lint.
|
|
44
44
|
- **JavaScript** — ESLint's recommended rules plus the bundled `eslint-plugin-theory`, over modified `.js` and `.mjs` files *and* the JavaScript inside `{% javascript %}` blocks of modified `.liquid` files.
|
|
45
45
|
- **CSS** — `stylelint-config-standard` through the bundled `stylelint-config-theory`, over modified `.css` files *and* the CSS inside `{% stylesheet %}` blocks of modified `.liquid` files.
|
|
46
46
|
|
|
@@ -48,7 +48,7 @@ Standalone JavaScript and CSS are linted only when the **filename** contains `td
|
|
|
48
48
|
|
|
49
49
|
The filter does **not** apply to Liquid: `{% javascript %}` and `{% stylesheet %}` blocks are linted in every modified `.liquid` file regardless of its name, because that code is Theory's either way. A `{% stylesheet 'scss' %}` block is skipped, because its body is Sass rather than CSS, as is any block commented out with `{% comment %}` or `{% raw %}`.
|
|
50
50
|
|
|
51
|
-
Offenses in a Liquid block are reported at their real line and column in the `.liquid` file. Check codes say where they came from: `PascalCase` is Theme Check, `theory/…` is a Theory JavaScript rule, `eslint/…` is an upstream ESLint rule, `stylelint/…` is an upstream Stylelint rule.
|
|
51
|
+
Offenses in a Liquid block are reported at their real line and column in the `.liquid` file. Positions are one-indexed in both the JSON and the text report, matching the `file:line:column` convention editors and compilers use — Theme Check's LSP-style zero-indexed rows and columns are rebased on the way in, and ESLint's and Stylelint's pass through as reported. Check codes say where they came from: `PascalCase` is Theme Check, `theory/…` is a Theory JavaScript rule, `eslint/…` is an upstream ESLint rule, `stylelint/…` is an upstream Stylelint rule.
|
|
52
52
|
|
|
53
53
|
`theory/guarded-custom-element-define` and `theory/no-unapproved-imports` are errors, as are genuine ESLint correctness failures; the convention rules (`theory/disconnected-callback-cleanup`, `theory/no-class-selectors`, `theory/centralize-selectors`, `theory/require-jsdoc`) are warnings. `theory/require-jsdoc` requires named classes and functions to have JSDoc comments, including typed `@param` and `@returns` tags. The severity distinction only affects `--fail-level`: the Stop hooks run at `--fail-level warning`, so warnings block handoff too. See `eslint-plugin-theory/README.md` for every rule and its blind spots.
|
|
54
54
|
|
|
@@ -1297,9 +1297,9 @@
|
|
|
1297
1297
|
"license": "MIT"
|
|
1298
1298
|
},
|
|
1299
1299
|
"node_modules/brace-expansion": {
|
|
1300
|
-
"version": "1.1.
|
|
1301
|
-
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.
|
|
1302
|
-
"integrity": "sha512-
|
|
1300
|
+
"version": "1.1.18",
|
|
1301
|
+
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
|
1302
|
+
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
|
1303
1303
|
"license": "MIT",
|
|
1304
1304
|
"dependencies": {
|
|
1305
1305
|
"balanced-match": "^1.0.0",
|
|
@@ -199,6 +199,42 @@ describe('maskLiquid', () => {
|
|
|
199
199
|
expect(maskLiquid(source)).toBeNull();
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
it('skips a block inside a doc tag', () => {
|
|
203
|
+
// An `@example` shows the block a snippet expects its caller to provide; it
|
|
204
|
+
// is documentation, not code the storefront runs.
|
|
205
|
+
const source = [
|
|
206
|
+
'{% doc %}',
|
|
207
|
+
' @example',
|
|
208
|
+
' {% javascript %}',
|
|
209
|
+
' run();',
|
|
210
|
+
' {% endjavascript %}',
|
|
211
|
+
'{% enddoc %}',
|
|
212
|
+
'',
|
|
213
|
+
].join('\n');
|
|
214
|
+
expect(maskLiquid(source)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('still lints a live block alongside a documented one', () => {
|
|
218
|
+
const masked =
|
|
219
|
+
maskLiquid(
|
|
220
|
+
[
|
|
221
|
+
'{% doc %}',
|
|
222
|
+
' @example',
|
|
223
|
+
' {% javascript %}',
|
|
224
|
+
' example();',
|
|
225
|
+
' {% endjavascript %}',
|
|
226
|
+
'{% enddoc %}',
|
|
227
|
+
'{% javascript %}',
|
|
228
|
+
'live();',
|
|
229
|
+
'{% endjavascript %}',
|
|
230
|
+
'',
|
|
231
|
+
].join('\n'),
|
|
232
|
+
) ?? '';
|
|
233
|
+
|
|
234
|
+
expect(masked).toContain('live();');
|
|
235
|
+
expect(masked).not.toContain('example();');
|
|
236
|
+
});
|
|
237
|
+
|
|
202
238
|
it('still lints a live block alongside a commented-out one', () => {
|
|
203
239
|
const masked =
|
|
204
240
|
maskLiquid(
|
|
@@ -19,8 +19,14 @@
|
|
|
19
19
|
/** Matches a `{% javascript %}` … `{% endjavascript %}` pair, capturing the body. */
|
|
20
20
|
const JAVASCRIPT_BLOCK = /(\{%-?\s*javascript\s*-?%\})([\s\S]*?)(\{%-?\s*endjavascript\s*-?%\})/g;
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Liquid tags whose bodies are not Liquid and must be masked wholesale.
|
|
24
|
+
*
|
|
25
|
+
* `{% doc %}` is included because a documented snippet's `@example` routinely
|
|
26
|
+
* shows the very `{% javascript %}` block the snippet expects — sample code
|
|
27
|
+
* that is never executed and must not be linted.
|
|
28
|
+
*/
|
|
29
|
+
const RAW_CONTENT_TAG = /\{%-?\s*(comment|doc|raw)\s*-?%\}[\s\S]*?\{%-?\s*end\1\s*-?%\}/g;
|
|
24
30
|
|
|
25
31
|
/** A Liquid output tag, e.g. `{{ section.settings.foo | json }}`. */
|
|
26
32
|
const OUTPUT_TAG = /\{\{[\s\S]*?\}\}/g;
|
|
@@ -74,7 +80,7 @@ function contains(intervals: Interval[], offset: number): boolean {
|
|
|
74
80
|
return intervals.some((interval) => offset >= interval.start && offset < interval.end);
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
/** Every
|
|
83
|
+
/** Every raw-content region in the source, in order. */
|
|
78
84
|
function rawContentRegions(source: string): Interval[] {
|
|
79
85
|
return [...source.matchAll(RAW_CONTENT_TAG)].map((match) => ({
|
|
80
86
|
start: match.index,
|
|
@@ -85,8 +91,8 @@ function rawContentRegions(source: string): Interval[] {
|
|
|
85
91
|
/**
|
|
86
92
|
* Mask the Liquid constructs inside one `{% javascript %}` body, in place.
|
|
87
93
|
*
|
|
88
|
-
* Order matters:
|
|
89
|
-
*
|
|
94
|
+
* Order matters: raw-content bodies are masked first so that Liquid-looking
|
|
95
|
+
* text inside them is not substituted a second time.
|
|
90
96
|
*/
|
|
91
97
|
function maskLiquidInBody(source: string, out: string[], start: number, end: number): void {
|
|
92
98
|
const body = source.slice(start, end);
|
|
@@ -134,9 +140,10 @@ function maskLiquidInBody(source: string, out: string[], start: number, end: num
|
|
|
134
140
|
*
|
|
135
141
|
* Returns `null` when the file contains no complete `{% javascript %}` block
|
|
136
142
|
* that is live code, which lets the processor skip the file entirely. A block
|
|
137
|
-
* that sits inside a `{% comment %}` or `{% raw %}` region does
|
|
138
|
-
* commented-out blocks are common mid-migration
|
|
139
|
-
* offenses in code the
|
|
143
|
+
* that sits inside a `{% comment %}`, `{% doc %}`, or `{% raw %}` region does
|
|
144
|
+
* not count: commented-out blocks are common mid-migration and documented ones
|
|
145
|
+
* are illustrations, so linting either would report offenses in code the
|
|
146
|
+
* storefront never runs.
|
|
140
147
|
*/
|
|
141
148
|
export function maskLiquid(source: string): string | null {
|
|
142
149
|
// Indexed per UTF-16 code unit, not per code point, so that offsets stay
|
|
@@ -151,7 +158,7 @@ export function maskLiquid(source: string): string | null {
|
|
|
151
158
|
|
|
152
159
|
for (const match of source.matchAll(JAVASCRIPT_BLOCK)) {
|
|
153
160
|
if (contains(rawRegions, match.index)) {
|
|
154
|
-
// A commented-out or raw-quoted block is not live JavaScript.
|
|
161
|
+
// A commented-out, documented, or raw-quoted block is not live JavaScript.
|
|
155
162
|
continue;
|
|
156
163
|
}
|
|
157
164
|
found = true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"4.1.10","results":[[":eslint-plugin-theory/src/rules/guarded-custom-element-define.test.ts",{"duration":0,"failed":true}]]}
|
|
1
|
+
{"version":"4.1.10","results":[[":eslint-plugin-theory/src/rules/guarded-custom-element-define.test.ts",{"duration":0,"failed":true}],[":stylelint-config-theory/src/liquid/mask.test.ts",{"duration":21.486761,"failed":false}],[":theme-check-theory/src/checks/hardcoded-text.test.ts",{"duration":79.642067,"failed":false}],[":stylelint-config-theory/src/integration.test.ts",{"duration":322.234405,"failed":false}],[":theme-check-theory/src/checks/unguarded-nullable-setting.test.ts",{"duration":126.54217099999994,"failed":false}],[":eslint-plugin-theory/src/rules/disconnected-callback-cleanup.test.ts",{"duration":0,"failed":true}],[":eslint-plugin-theory/src/integration.test.ts",{"duration":207.055841,"failed":false}],[":eslint-plugin-theory/src/liquid/mask.test.ts",{"duration":31.769655,"failed":false}],[":stylelint-config-theory/src/config.test.ts",{"duration":13.856021999999996,"failed":false}],[":theme-check-theory/src/checks/unused-section-settings.test.ts",{"duration":63.80623400000002,"failed":false}],[":theme-check-theory/src/checks/unguarded-metaobject.test.ts",{"duration":34.325909000000024,"failed":false}],[":eslint-plugin-theory/src/index.test.ts",{"duration":6.773288999999977,"failed":false}],[":theme-check-theory/src/checks/disallowed-script-or-style-tag.test.ts",{"duration":32.848730000000046,"failed":false}],[":theme-check-theory/src/checks/unguarded-metafield.test.ts",{"duration":37.897699999999986,"failed":false}],[":eslint-plugin-theory/src/rules/no-class-selectors.test.ts",{"duration":0,"failed":true}],[":eslint-plugin-theory/src/rules/no-unapproved-imports.test.ts",{"duration":0,"failed":true}],[":eslint-plugin-theory/src/rules/require-jsdoc.test.ts",{"duration":87.66796799999997,"failed":false}],[":eslint-plugin-theory/src/rules/centralize-selectors.test.ts",{"duration":0,"failed":true}],[":stylelint-config-theory/src/build.test.ts",{"duration":903.9329710000001,"failed":false}],[":theme-check-theory/src/index.test.ts",{"duration":2.380390000000034,"failed":false}],[":eslint-plugin-theory/src/processors/liquid.test.ts",{"duration":6.720345000000009,"failed":false}],[":eslint-plugin-theory/src/build.test.ts",{"duration":965.007413,"failed":false}]]}
|
|
Binary file
|
|
@@ -41,9 +41,21 @@ STYLELINT_SEVERITIES = ("error", "warning")
|
|
|
41
41
|
STYLELINT_PARSE_ERROR_RULE = "CssSyntaxError"
|
|
42
42
|
|
|
43
43
|
# Theme Check reports LSP-style zero-indexed positions; ESLint and Stylelint are
|
|
44
|
-
# both one-indexed.
|
|
44
|
+
# both one-indexed. Positions are reported one-indexed throughout — the
|
|
45
|
+
# `file:line:column` convention every editor and compiler uses — so Theme
|
|
46
|
+
# Check's rows and columns are each shifted up by one and the other two runners'
|
|
47
|
+
# pass through untouched.
|
|
45
48
|
LINE_BASE_OFFSET = 1
|
|
46
49
|
|
|
50
|
+
# Position fields carried on a Theme Check offense, rebased together so a single
|
|
51
|
+
# offense never mixes indexing bases.
|
|
52
|
+
THEME_CHECK_POSITION_KEYS = (
|
|
53
|
+
"start_row",
|
|
54
|
+
"start_column",
|
|
55
|
+
"end_row",
|
|
56
|
+
"end_column",
|
|
57
|
+
)
|
|
58
|
+
|
|
47
59
|
|
|
48
60
|
class ShopifyLintError(RuntimeError):
|
|
49
61
|
"""Raised when Git or Shopify CLI cannot produce filterable results."""
|
|
@@ -169,7 +181,26 @@ def run_theme_check(
|
|
|
169
181
|
|
|
170
182
|
if not isinstance(reports, list):
|
|
171
183
|
raise ShopifyLintError("Shopify Theme Check returned an unexpected JSON shape.")
|
|
172
|
-
return reports
|
|
184
|
+
return normalize_theme_check_reports(reports)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def normalize_theme_check_reports(
|
|
188
|
+
reports: list[dict[str, Any]],
|
|
189
|
+
) -> list[dict[str, Any]]:
|
|
190
|
+
"""Rebase Theme Check's zero-indexed positions onto the report's one-indexed ones."""
|
|
191
|
+
normalized = []
|
|
192
|
+
for report in reports:
|
|
193
|
+
offenses = []
|
|
194
|
+
for offense in report.get("offenses", []):
|
|
195
|
+
item = dict(offense)
|
|
196
|
+
for key in THEME_CHECK_POSITION_KEYS:
|
|
197
|
+
if key in item:
|
|
198
|
+
item[key] = int(item[key]) + LINE_BASE_OFFSET
|
|
199
|
+
offenses.append(item)
|
|
200
|
+
entry = dict(report)
|
|
201
|
+
entry["offenses"] = offenses
|
|
202
|
+
normalized.append(entry)
|
|
203
|
+
return normalized
|
|
173
204
|
|
|
174
205
|
|
|
175
206
|
def resolve_lint_target(
|
|
@@ -373,9 +404,9 @@ def normalize_stylelint_results(results: list[dict[str, Any]]) -> list[dict[str,
|
|
|
373
404
|
"check": stylelint_check_code(warning),
|
|
374
405
|
"severity": stylelint_severity(warning),
|
|
375
406
|
"message": str(warning.get("text", "")),
|
|
376
|
-
"start_row": max(int(warning.get("line", 1))
|
|
407
|
+
"start_row": max(int(warning.get("line", 1)), LINE_BASE_OFFSET),
|
|
377
408
|
"start_column": max(
|
|
378
|
-
int(warning.get("column", 1))
|
|
409
|
+
int(warning.get("column", 1)), LINE_BASE_OFFSET
|
|
379
410
|
),
|
|
380
411
|
}
|
|
381
412
|
)
|
|
@@ -420,9 +451,9 @@ def normalize_eslint_results(results: list[dict[str, Any]]) -> list[dict[str, An
|
|
|
420
451
|
"check": eslint_check_code(message),
|
|
421
452
|
"severity": eslint_severity(message),
|
|
422
453
|
"message": str(message.get("message", "")),
|
|
423
|
-
"start_row": max(int(message.get("line", 1))
|
|
454
|
+
"start_row": max(int(message.get("line", 1)), LINE_BASE_OFFSET),
|
|
424
455
|
"start_column": max(
|
|
425
|
-
int(message.get("column", 1))
|
|
456
|
+
int(message.get("column", 1)), LINE_BASE_OFFSET
|
|
426
457
|
),
|
|
427
458
|
}
|
|
428
459
|
)
|
|
@@ -510,8 +541,8 @@ def render_text(reports: list[dict[str, Any]], repo_root: Path) -> str:
|
|
|
510
541
|
offense_count += 1
|
|
511
542
|
severity = str(offense.get("severity", "unknown")).upper()
|
|
512
543
|
check = offense.get("check", "UnknownCheck")
|
|
513
|
-
row = offense.get("start_row",
|
|
514
|
-
column = offense.get("start_column",
|
|
544
|
+
row = offense.get("start_row", LINE_BASE_OFFSET)
|
|
545
|
+
column = offense.get("start_column", LINE_BASE_OFFSET)
|
|
515
546
|
message = offense.get("message", "")
|
|
516
547
|
lines.append(f" {severity} {check} {row}:{column} {message}".rstrip())
|
|
517
548
|
lines.append("")
|
|
@@ -251,6 +251,45 @@ describe('maskLiquidStylesheet', () => {
|
|
|
251
251
|
).toBeNull();
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
+
it('skips a block inside a doc tag', () => {
|
|
255
|
+
// An `@example` shows the block a snippet expects its caller to provide; it
|
|
256
|
+
// is documentation, not CSS the storefront renders.
|
|
257
|
+
expect(
|
|
258
|
+
maskLiquidStylesheet(
|
|
259
|
+
[
|
|
260
|
+
'{% doc %}',
|
|
261
|
+
' @example',
|
|
262
|
+
' {% stylesheet %}',
|
|
263
|
+
' .a { color: red; }',
|
|
264
|
+
' {% endstylesheet %}',
|
|
265
|
+
'{% enddoc %}',
|
|
266
|
+
'',
|
|
267
|
+
].join('\n'),
|
|
268
|
+
),
|
|
269
|
+
).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('still lints a live block alongside a documented one', () => {
|
|
273
|
+
const masked = maskLiquidStylesheet(
|
|
274
|
+
[
|
|
275
|
+
'{% doc %}',
|
|
276
|
+
' @example',
|
|
277
|
+
' {% stylesheet %}',
|
|
278
|
+
' .old { color: red; }',
|
|
279
|
+
' {% endstylesheet %}',
|
|
280
|
+
'{% enddoc %}',
|
|
281
|
+
'{% stylesheet %}',
|
|
282
|
+
'.new { color: blue; }',
|
|
283
|
+
'{% endstylesheet %}',
|
|
284
|
+
'',
|
|
285
|
+
].join('\n'),
|
|
286
|
+
)!;
|
|
287
|
+
const lines = masked.split('\n');
|
|
288
|
+
|
|
289
|
+
expect(lines[3]!.trim()).toBe('');
|
|
290
|
+
expect(lines[7]).toBe('.new { color: blue; }');
|
|
291
|
+
});
|
|
292
|
+
|
|
254
293
|
it('still lints a live block alongside a commented-out one', () => {
|
|
255
294
|
const masked = maskLiquidStylesheet(
|
|
256
295
|
[
|
|
@@ -27,8 +27,14 @@
|
|
|
27
27
|
const STYLESHEET_BLOCK =
|
|
28
28
|
/(\{%-?\s*stylesheet\b([^%]*?)-?%\})([\s\S]*?)(\{%-?\s*endstylesheet\s*-?%\})/g;
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Liquid tags whose bodies are not Liquid and must be masked wholesale.
|
|
32
|
+
*
|
|
33
|
+
* `{% doc %}` is included because a documented snippet's `@example` routinely
|
|
34
|
+
* shows the very `{% stylesheet %}` block the snippet expects — sample code
|
|
35
|
+
* that is never rendered and must not be linted.
|
|
36
|
+
*/
|
|
37
|
+
const RAW_CONTENT_TAG = /\{%-?\s*(comment|doc|raw)\s*-?%\}[\s\S]*?\{%-?\s*end\1\s*-?%\}/g;
|
|
32
38
|
|
|
33
39
|
/** A Liquid output tag, e.g. `{{ section.settings.accent | default: '#000' }}`. */
|
|
34
40
|
const OUTPUT_TAG = /\{\{[\s\S]*?\}\}/g;
|
|
@@ -84,7 +90,7 @@ function contains(intervals: Interval[], offset: number): boolean {
|
|
|
84
90
|
return intervals.some((interval) => offset >= interval.start && offset < interval.end);
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
/** Every
|
|
93
|
+
/** Every raw-content region in the source, in order. */
|
|
88
94
|
function rawContentRegions(source: string): Interval[] {
|
|
89
95
|
return [...source.matchAll(RAW_CONTENT_TAG)].map((match) => ({
|
|
90
96
|
start: match.index,
|
|
@@ -95,8 +101,8 @@ function rawContentRegions(source: string): Interval[] {
|
|
|
95
101
|
/**
|
|
96
102
|
* Mask the Liquid constructs inside one `{% stylesheet %}` body, in place.
|
|
97
103
|
*
|
|
98
|
-
* Order matters:
|
|
99
|
-
*
|
|
104
|
+
* Order matters: raw-content bodies are masked first so that Liquid-looking
|
|
105
|
+
* text inside them is not substituted a second time.
|
|
100
106
|
*
|
|
101
107
|
* Shopify does not render Liquid inside a `{% stylesheet %}` tag, so any
|
|
102
108
|
* interpolation found here is already broken theme code, and Theme Check's
|
|
@@ -156,8 +162,9 @@ function maskLiquidInBody(source: string, out: string[], start: number, end: num
|
|
|
156
162
|
* when:
|
|
157
163
|
*
|
|
158
164
|
* - there is no complete `{% stylesheet %}` … `{% endstylesheet %}` pair;
|
|
159
|
-
* - every such block sits inside a `{% comment %}` or `{% raw %}`
|
|
160
|
-
* commented-out blocks are common mid-migration
|
|
165
|
+
* - every such block sits inside a `{% comment %}`, `{% doc %}`, or `{% raw %}`
|
|
166
|
+
* region — commented-out blocks are common mid-migration, and a documented
|
|
167
|
+
* block is an illustration rather than live CSS;
|
|
161
168
|
* - every block carries an argument (`{% stylesheet 'scss' %}`), whose body is
|
|
162
169
|
* Sass rather than CSS and would only produce parse errors; or
|
|
163
170
|
* - every block's body is blank once masked, which would otherwise be reported
|
|
@@ -178,7 +185,7 @@ export function maskLiquidStylesheet(source: string): string | null {
|
|
|
178
185
|
for (const match of source.matchAll(STYLESHEET_BLOCK)) {
|
|
179
186
|
const blockStart = match.index;
|
|
180
187
|
if (contains(rawRegions, blockStart)) {
|
|
181
|
-
// A commented-out or raw-quoted block is not live CSS.
|
|
188
|
+
// A commented-out, documented, or raw-quoted block is not live CSS.
|
|
182
189
|
continue;
|
|
183
190
|
}
|
|
184
191
|
if (match[2]!.trim() !== '') {
|
|
Binary file
|
|
@@ -80,6 +80,35 @@ class ThemeCheckTests(unittest.TestCase):
|
|
|
80
80
|
check=False,
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
+
def test_rebases_theme_check_positions_onto_one_indexed_lines(self) -> None:
|
|
84
|
+
"""Theme Check emits LSP-style zero-indexed positions; the report is one-indexed."""
|
|
85
|
+
reports = [
|
|
86
|
+
{
|
|
87
|
+
"path": "/repo/sections/td-hero.liquid",
|
|
88
|
+
"offenses": [
|
|
89
|
+
{
|
|
90
|
+
"check": "HardcodedText",
|
|
91
|
+
"severity": "warning",
|
|
92
|
+
"start_row": 0,
|
|
93
|
+
"start_column": 4,
|
|
94
|
+
"end_row": 2,
|
|
95
|
+
"end_column": 9,
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
[report] = shopify_lint.normalize_theme_check_reports(reports)
|
|
102
|
+
offense = report["offenses"][0]
|
|
103
|
+
|
|
104
|
+
self.assertEqual(offense["start_row"], 1)
|
|
105
|
+
self.assertEqual(offense["end_row"], 3)
|
|
106
|
+
self.assertEqual(offense["start_column"], 5)
|
|
107
|
+
self.assertEqual(offense["end_column"], 10)
|
|
108
|
+
# The input is not mutated in place.
|
|
109
|
+
self.assertEqual(reports[0]["offenses"][0]["start_row"], 0)
|
|
110
|
+
self.assertEqual(reports[0]["offenses"][0]["start_column"], 4)
|
|
111
|
+
|
|
83
112
|
def test_filters_reports_to_changed_repo_relative_paths(self) -> None:
|
|
84
113
|
root = Path("/repo")
|
|
85
114
|
reports = [
|
|
@@ -360,7 +389,7 @@ class JsLintCommandTests(unittest.TestCase):
|
|
|
360
389
|
|
|
361
390
|
|
|
362
391
|
class EslintNormalizationTests(unittest.TestCase):
|
|
363
|
-
def
|
|
392
|
+
def test_maps_severity_rule_and_position(self) -> None:
|
|
364
393
|
results = [
|
|
365
394
|
{
|
|
366
395
|
"filePath": "/repo/assets/td-app.js",
|
|
@@ -395,15 +424,15 @@ class EslintNormalizationTests(unittest.TestCase):
|
|
|
395
424
|
"check": "eslint/no-undef",
|
|
396
425
|
"severity": "error",
|
|
397
426
|
"message": "'x' is not defined.",
|
|
398
|
-
"start_row":
|
|
399
|
-
"start_column":
|
|
427
|
+
"start_row": 4,
|
|
428
|
+
"start_column": 3,
|
|
400
429
|
},
|
|
401
430
|
{
|
|
402
431
|
"check": "theory/no-class-selectors",
|
|
403
432
|
"severity": "warning",
|
|
404
433
|
"message": "Use a data-td-* hook.",
|
|
405
|
-
"start_row":
|
|
406
|
-
"start_column":
|
|
434
|
+
"start_row": 9,
|
|
435
|
+
"start_column": 1,
|
|
407
436
|
},
|
|
408
437
|
],
|
|
409
438
|
}
|
|
@@ -684,7 +713,7 @@ class CssLintCommandTests(unittest.TestCase):
|
|
|
684
713
|
|
|
685
714
|
|
|
686
715
|
class StylelintNormalizationTests(unittest.TestCase):
|
|
687
|
-
def
|
|
716
|
+
def test_namespaces_rules_and_maps_positions(self) -> None:
|
|
688
717
|
results = [
|
|
689
718
|
{
|
|
690
719
|
"path": "/theme/assets/component-td-card.css",
|
|
@@ -705,8 +734,8 @@ class StylelintNormalizationTests(unittest.TestCase):
|
|
|
705
734
|
|
|
706
735
|
self.assertEqual(offense["check"], "stylelint/length-zero-no-unit")
|
|
707
736
|
self.assertEqual(offense["severity"], "warning")
|
|
708
|
-
self.assertEqual(offense["start_row"],
|
|
709
|
-
self.assertEqual(offense["start_column"],
|
|
737
|
+
self.assertEqual(offense["start_row"], 4)
|
|
738
|
+
self.assertEqual(offense["start_column"], 3)
|
|
710
739
|
|
|
711
740
|
def test_keeps_a_namespaced_plugin_rule_as_is(self) -> None:
|
|
712
741
|
results = [
|
|
@@ -932,6 +961,32 @@ class MergeReportTests(unittest.TestCase):
|
|
|
932
961
|
(SKILL_ROOT / "eslint-plugin-theory" / "dist").is_dir(),
|
|
933
962
|
"run scripts/setup.sh to build eslint-plugin-theory",
|
|
934
963
|
)
|
|
964
|
+
class RenderTextTests(unittest.TestCase):
|
|
965
|
+
"""The Stop hooks feed this text straight back to the agent."""
|
|
966
|
+
|
|
967
|
+
def test_prints_the_one_indexed_row_the_offense_carries(self) -> None:
|
|
968
|
+
reports = [
|
|
969
|
+
{
|
|
970
|
+
"path": "/repo/sections/td-hero.liquid",
|
|
971
|
+
"offenses": [
|
|
972
|
+
{
|
|
973
|
+
"check": "HardcodedText",
|
|
974
|
+
"severity": "warning",
|
|
975
|
+
"start_row": 12,
|
|
976
|
+
"start_column": 4,
|
|
977
|
+
"message": "Replace hard-coded text",
|
|
978
|
+
}
|
|
979
|
+
],
|
|
980
|
+
}
|
|
981
|
+
]
|
|
982
|
+
|
|
983
|
+
rendered = shopify_lint.render_text(reports, Path("/repo"))
|
|
984
|
+
|
|
985
|
+
self.assertIn(
|
|
986
|
+
" WARNING HardcodedText 12:4 Replace hard-coded text", rendered
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
|
|
935
990
|
class EndToEndJsTests(unittest.TestCase):
|
|
936
991
|
"""Proves the Liquid line-mapping contract against the real runner."""
|
|
937
992
|
|
|
@@ -951,7 +1006,7 @@ class EndToEndJsTests(unittest.TestCase):
|
|
|
951
1006
|
" {% endif %}",
|
|
952
1007
|
"{% endjavascript %}",
|
|
953
1008
|
]
|
|
954
|
-
expected_row = lines.index(" const dupe = { a: 1, a: 2 };")
|
|
1009
|
+
expected_row = lines.index(" const dupe = { a: 1, a: 2 };") + 1
|
|
955
1010
|
|
|
956
1011
|
with tempfile.TemporaryDirectory() as directory:
|
|
957
1012
|
root = Path(directory)
|
|
@@ -969,7 +1024,7 @@ class EndToEndJsTests(unittest.TestCase):
|
|
|
969
1024
|
]
|
|
970
1025
|
|
|
971
1026
|
self.assertEqual(len(dupe), 1)
|
|
972
|
-
# start_row is
|
|
1027
|
+
# start_row is one-indexed, matching the line numbers an editor shows.
|
|
973
1028
|
self.assertEqual(dupe[0]["start_row"], expected_row)
|
|
974
1029
|
self.assertEqual(dupe[0]["severity"], "error")
|
|
975
1030
|
|
|
@@ -994,7 +1049,7 @@ class EndToEndCssTests(unittest.TestCase):
|
|
|
994
1049
|
" .td-broken {}",
|
|
995
1050
|
"{% endstylesheet %}",
|
|
996
1051
|
]
|
|
997
|
-
expected_row = lines.index(" .td-broken {}")
|
|
1052
|
+
expected_row = lines.index(" .td-broken {}") + 1
|
|
998
1053
|
|
|
999
1054
|
with tempfile.TemporaryDirectory() as directory:
|
|
1000
1055
|
root = Path(directory)
|
|
@@ -1012,13 +1067,13 @@ class EndToEndCssTests(unittest.TestCase):
|
|
|
1012
1067
|
]
|
|
1013
1068
|
|
|
1014
1069
|
self.assertEqual(len(empty), 1)
|
|
1015
|
-
# start_row is
|
|
1070
|
+
# start_row is one-indexed, matching the line numbers an editor shows.
|
|
1016
1071
|
self.assertEqual(empty[0]["start_row"], expected_row)
|
|
1017
1072
|
self.assertEqual(empty[0]["severity"], "error")
|
|
1018
1073
|
# The masked interpolation is graded like any other value now: Liquid
|
|
1019
1074
|
# does not belong in a `{% stylesheet %}` block, and Theme Check reports
|
|
1020
1075
|
# it separately through `StaticStylesheetAndJavascriptTags`.
|
|
1021
|
-
interpolated_row = lines.index(" margin: {{ section.settings.gap }}px;")
|
|
1076
|
+
interpolated_row = lines.index(" margin: {{ section.settings.gap }}px;") + 1
|
|
1022
1077
|
self.assertEqual(
|
|
1023
1078
|
[
|
|
1024
1079
|
offense["check"]
|
|
@@ -39,6 +39,13 @@ checked. It ignores technical attributes such as classes and IDs, Liquid logic
|
|
|
39
39
|
strings, comments, punctuation-only fragments, and text made entirely of HTML
|
|
40
40
|
character references.
|
|
41
41
|
|
|
42
|
+
Text beginning with `--` is ignored as a CSS custom property name: Liquid
|
|
43
|
+
routinely assembles a custom property declaration before handing it to a
|
|
44
|
+
`style` attribute or a `{% style %}` block, neither of which this check reads.
|
|
45
|
+
Only the fragment carrying the `--` prefix is exempt, so the tail of an
|
|
46
|
+
interpolated declaration — the `px;` in
|
|
47
|
+
`{% capture s %}--td-gap: {{ gap }}px;{% endcapture %}` — is still reported.
|
|
48
|
+
|
|
42
49
|
Content inside `{% stylesheet %}`, `{% javascript %}`, and `{% schema %}` is
|
|
43
50
|
ignored. A quoted translation key passed through `t` or `translate` is also
|
|
44
51
|
accepted. Short alphabetic tokens and copyright years remain reportable because
|
|
@@ -1071,16 +1071,16 @@
|
|
|
1071
1071
|
}
|
|
1072
1072
|
},
|
|
1073
1073
|
"node_modules/brace-expansion": {
|
|
1074
|
-
"version": "5.0.
|
|
1075
|
-
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.
|
|
1076
|
-
"integrity": "sha512-
|
|
1074
|
+
"version": "5.0.9",
|
|
1075
|
+
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
1076
|
+
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
1077
1077
|
"dev": true,
|
|
1078
1078
|
"license": "MIT",
|
|
1079
1079
|
"dependencies": {
|
|
1080
1080
|
"balanced-match": "^4.0.2"
|
|
1081
1081
|
},
|
|
1082
1082
|
"engines": {
|
|
1083
|
-
"node": "
|
|
1083
|
+
"node": "20 || >=22"
|
|
1084
1084
|
}
|
|
1085
1085
|
},
|
|
1086
1086
|
"node_modules/cac": {
|
|
@@ -165,10 +165,38 @@ describe('HardcodedText', () => {
|
|
|
165
165
|
'schema',
|
|
166
166
|
'{% schema %}{"name":"Hard coded","settings":[]}{% endschema %}',
|
|
167
167
|
],
|
|
168
|
+
[
|
|
169
|
+
'doc',
|
|
170
|
+
'{% doc %}Hard coded documentation{% enddoc %}',
|
|
171
|
+
],
|
|
168
172
|
])('ignores text inside the %s raw tag', async (_, source) => {
|
|
169
173
|
expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
|
|
170
174
|
});
|
|
171
175
|
|
|
176
|
+
it('ignores every part of a documentation block', async () => {
|
|
177
|
+
// The parser breaks a `{% doc %}` body into description, param, and example
|
|
178
|
+
// nodes, each carrying its own text — none of it rendered to the storefront.
|
|
179
|
+
const source = `{% doc %}
|
|
180
|
+
Renders a product card.
|
|
181
|
+
|
|
182
|
+
@param {product} product - The product to render
|
|
183
|
+
@param {string} [heading] - Optional heading text
|
|
184
|
+
@example
|
|
185
|
+
{% render 'card', product: product, heading: 'Featured' %}
|
|
186
|
+
{% enddoc %}`;
|
|
187
|
+
|
|
188
|
+
expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('still reports rendered text beside a documentation block', async () => {
|
|
192
|
+
const source = `{% doc %}
|
|
193
|
+
Renders a product card.
|
|
194
|
+
{% enddoc %}
|
|
195
|
+
<h2>Featured products</h2>`;
|
|
196
|
+
|
|
197
|
+
expect(await runLiquidCheck(HardcodedText, source)).toHaveLength(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
172
200
|
it('ignores comments, technical attributes, punctuation, and Liquid logic strings', async () => {
|
|
173
201
|
const source = `
|
|
174
202
|
<!-- Hard coded HTML comment -->
|
|
@@ -181,6 +209,38 @@ describe('HardcodedText', () => {
|
|
|
181
209
|
expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
|
|
182
210
|
});
|
|
183
211
|
|
|
212
|
+
it('accepts CSS custom properties built in Liquid', async () => {
|
|
213
|
+
const source = `
|
|
214
|
+
{% capture card_style %}--td-card-gap: 12px;{% endcapture %}
|
|
215
|
+
{{ '--td-card-columns' }}
|
|
216
|
+
{% echo "--td-card-radius: 4px;" %}
|
|
217
|
+
{% render 'card', style_key: '--td-card-accent' %}
|
|
218
|
+
<div style="{{ card_style }}">{{ section.settings.heading }}</div>
|
|
219
|
+
`;
|
|
220
|
+
|
|
221
|
+
expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('still reports the tail of an interpolated custom property', async () => {
|
|
225
|
+
// The parser splits the declaration around the output tag, and only the
|
|
226
|
+
// fragment that carries the `--` prefix is exempt. The trailing unit is a
|
|
227
|
+
// text node of its own with nothing marking it as CSS.
|
|
228
|
+
const source =
|
|
229
|
+
'{% capture style %}--td-gap: {{ section.settings.gap }}px;{% endcapture %}';
|
|
230
|
+
|
|
231
|
+
const offenses = await runLiquidCheck(HardcodedText, source);
|
|
232
|
+
|
|
233
|
+
expect(
|
|
234
|
+
offenses.map(({ start, end }) => source.slice(start.index, end.index)),
|
|
235
|
+
).toEqual(['px;']);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('still reports copy that only mentions a custom property later', async () => {
|
|
239
|
+
const source = '{% capture note %}Set --td-gap to widen the card{% endcapture %}';
|
|
240
|
+
|
|
241
|
+
expect(await runLiquidCheck(HardcodedText, source)).toHaveLength(1);
|
|
242
|
+
});
|
|
243
|
+
|
|
184
244
|
it('still reports rendered text inside other raw tags', async () => {
|
|
185
245
|
const source = '{% raw %}Hard coded raw output{% endraw %}';
|
|
186
246
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
|
|
22
22
|
const EXEMPT_RAW_TAGS = new Set([
|
|
23
23
|
'comment',
|
|
24
|
+
'doc',
|
|
24
25
|
'javascript',
|
|
25
26
|
'schema',
|
|
26
27
|
'stylesheet',
|
|
@@ -42,6 +43,12 @@ const BUTTON_INPUT_TYPES = new Set(['button', 'reset', 'submit']);
|
|
|
42
43
|
const HTML_CHARACTER_REFERENCE =
|
|
43
44
|
/&(?:#\d+|#x[\da-f]+|[a-z][\da-z]+);/giu;
|
|
44
45
|
|
|
46
|
+
// A leading `--` is a CSS custom property name, never storefront copy. Liquid
|
|
47
|
+
// routinely assembles custom property declarations before handing them to a
|
|
48
|
+
// `style` attribute or a `{% style %}` block — places this check overlooks —
|
|
49
|
+
// so the fragment that builds them is exempt too.
|
|
50
|
+
const CSS_CUSTOM_PROPERTY = /^\s*--/u;
|
|
51
|
+
|
|
45
52
|
type ValuedAttribute = Extract<AttributeNode, { value: unknown }>;
|
|
46
53
|
|
|
47
54
|
function isValuedAttribute(node: LiquidHtmlNode): node is ValuedAttribute {
|
|
@@ -123,6 +130,8 @@ function isInlineSvgMarkup(ancestors: LiquidHtmlNode[]): boolean {
|
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
function hasMeaningfulText(value: string): boolean {
|
|
133
|
+
if (CSS_CUSTOM_PROPERTY.test(value)) return false;
|
|
134
|
+
|
|
126
135
|
return /[\p{L}\p{N}]/u.test(
|
|
127
136
|
value.replace(HTML_CHARACTER_REFERENCE, ''),
|
|
128
137
|
);
|