AGC 小红书导出:validate 去 strict 与增强层告警,pack 加落点预警

- validate.mjs:scanJsSyntax 先 stripJsNoise 剥注释与字符串,`"#e8f4ff"` / `'#hit'` 不再误判为 ES2018
- validate.mjs:删除 MODERN_RUNTIME_API、CSS_MODERN_PATTERNS / CSS_MODERN_FEATURE 与 --strict,退出码只由 ERROR 决定
- pack.mjs:--zip-out 相对 cwd 且落在 <cwd>/.export/ 时打 workdir 警告,打印 cwd 与绝对落点
- SKILL.md:产物为项目根 .export/xhs-minitool.zip,game/ 子工程写 ../.export/...;参数表去掉 --strict
- 测试:增强层放行、JS 噪音不误报、真私有字段仍报、warning 也退出 0、workdir 预警
- manifest:vite-export-xhs-minitool 指纹与 version 同步到 2026-08-26.56
- decision-log.md:记录本次 validate/pack 收敛
This commit is contained in:
2026-10-06 17:09:49 +08:00
parent df744ae155
commit a96c0e6209
7 changed files with 157 additions and 45 deletions
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.55",
"version": "2026-08-26.56",
"skills": [
{
"name": "agc-unity-editor",
@@ -204,7 +204,7 @@
"scripts/validate.mjs",
"scripts/vite.config.xhs-minitool.mjs"
],
"sha256": "bcb8910018b5d98c17e616cd154146c08b0e5cb0d3c80d0b9e3635a711ffb6e6"
"sha256": "19dd6324e2b5c5a40b7b2b8b2589189dc96bc9784047fc363ac5643d020965b6"
}
]
}
@@ -11,7 +11,7 @@ metadata:
1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范.
2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项:
`references/manual-checks.md`
3. 使用 `scripts/pack.mjs` 打包成zip
3. 使用 `scripts/pack.mjs` 打包成zip; 产物必须是**项目根**的 `.export/xhs-minitool.zip`, 而 `--zip-out` 相对 cwd 解析: npm 脚本挂在 `game/` 子工程时写 `../.export/xhs-minitool.zip`
4. 跑通流程后请把需要的脚本配置复制( agc_install_skill_resource )到项目里(以免依赖skill), 并形成最终的打包脚本
* 不干扰正常的web构建
@@ -24,8 +24,8 @@ metadata:
| 脚本 | 参数 |
|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) |
| `scripts/validate.mjs` | `[project]`、`--json`、`--strict` |
| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>` |
| `scripts/validate.mjs` | `[project]`、`--json` |
| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>`(相对 cwd 解析;宿主在项目根 `.export/xhs-minitool.zip` 找产物) |
# 一些情况:
* 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论
@@ -100,6 +100,30 @@ test('pack 只打 zip,不再改写产物目录', () => {
);
});
test('--zip-out 落在 cwd 的 .export 下时给出 workdir 警告', () => {
writeBuiltDir('workdir');
const warnings = [];
const original = console.warn;
console.warn = (line) => warnings.push(String(line));
try {
// 可疑:相对 cwd 解析成 <cwd>/.export/...;cwd 若是 game/ 子工程就落错地方。
packMinitool({ cwd: BASE, viteBuiltDir: 'workdir', zipOut: '.export/xhs-minitool.zip' });
// 别的相对落点(不落在 cwd 的 .export 下)不提醒。
packMinitool({
cwd: BASE,
viteBuiltDir: 'workdir',
zipOut: join('other', 'xhs-minitool.zip'),
});
} finally {
console.warn = original;
}
assert.equal(
warnings.filter((line) => line.includes('--zip-out')).length,
1,
warnings.join('\n'),
);
});
test('CLI 拒绝不认识的参数,不写出 zip', () => {
const dir = writeBuiltDir('cli');
const zipOut = join(BASE, 'cli-out.zip');
@@ -250,7 +250,7 @@ test('html template and classic-script requirements are enforced', () => {
);
});
test('modern CSS and runtime APIs are flagged as warnings', () => {
test('现代 CSS / 运行时 API 作为增强层放行,不再报警', () => {
const root = join(BASE, 'modern_css');
writeProject(root, VALID_HTML, {
...VALID_FILES,
@@ -258,8 +258,29 @@ test('modern CSS and runtime APIs are flagged as warnings', () => {
'app.js': "list.replaceAll('a', 'b');\nObject.hasOwn({}, 'x');\n",
});
const actual = codes(root);
assert.ok(actual.has('CSS_MODERN_FEATURE'), `missing CSS_MODERN_FEATURE; actual=${[...actual].sort()}`);
assert.ok(actual.has('MODERN_RUNTIME_API'), `missing MODERN_RUNTIME_API; actual=${[...actual].sort()}`);
assert.ok(!actual.has('CSS_MODERN_FEATURE'), `unexpected CSS_MODERN_FEATURE; actual=${[...actual].sort()}`);
assert.ok(!actual.has('MODERN_RUNTIME_API'), `unexpected MODERN_RUNTIME_API; actual=${[...actual].sort()}`);
});
test('字符串与注释里的现代语法 / #hex / #选择器不算命中', () => {
const root = join(BASE, 'js_noise');
writeProject(root, VALID_HTML, {
...VALID_FILES,
'app.js': '// obj?.c ?? 2\n'
+ 'var color = "#e8f4ff";\n'
+ "var hit = document.querySelector('#hit');\n"
+ '// import.meta 也只出现在注释里\n',
});
assert.ok(!codes(root).has('ES2018_PLUS_SYNTAX'), `actual=${[...codes(root)].sort()}`);
});
test('真正的 class 私有字段仍然报错', () => {
const root = join(BASE, 'private_field_real');
writeProject(root, VALID_HTML, {
...VALID_FILES,
'app.js': 'class Counter { #count = 0; }\n',
});
assert.ok(codes(root).has('ES2018_PLUS_SYNTAX'), `actual=${[...codes(root)].sort()}`);
});
test('oversized inline base64 is warned', () => {
@@ -299,7 +320,7 @@ test('symlinks are rejected', (t) => {
assert.ok(codes(root).has('SYMLINK'));
});
test('CLI exit codes: pass, strict warning and error', () => {
test('CLI exit codes: pass, warning and error', () => {
const validRoot = join(BASE, 'valid_cli');
writeProject(validRoot, VALID_HTML, VALID_FILES);
const validCli = runCli(validRoot, '--json');
@@ -311,8 +332,8 @@ test('CLI exit codes: pass, strict warning and error', () => {
writeProject(warningRoot, '<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8">'
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">'
+ '</head><body><img src="data:image/png;base64,AA" alt=""></body></html>');
// 有 WARNING 也照样通过:退出码只由 ERROR 决定,没有 --strict 这种「警告即失败」的模式。
assert.equal(runCli(warningRoot).status, 0);
assert.equal(runCli(warningRoot, '--strict').status, 2);
const errorRoot = join(BASE, 'error_cli');
writeProject(errorRoot, '<!doctype html><script>bad()</script>');
@@ -17,7 +17,7 @@ import {
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { deflateRawSync } from 'node:zlib';
@@ -226,6 +226,23 @@ function makeZip(viteBuiltDir, zipPath) {
console.log(`zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB)`);
}
/**
* cwd 歧义预警:宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析。
* npm 脚本挂在 `game/` 子工程时 cwd 就是 `game/`,写 `.export/...` 会落到 `game/.export/...`,
* 导出判失败却没有任何提示。这里只在落点位于「当前 cwd 的 `.export/`」时提醒(`../.export/...`
* 或绝对路径不触发),把 cwd 与绝对落点打出来,让 workdir 问题不再静默。
*/
function warnIfWorkdirRelative(cwd, zipPath, zipOut) {
if (typeof zipOut !== 'string' || isAbsolute(zipOut)) return;
const relativeOut = relative(cwd, zipPath);
if (relativeOut.startsWith('..') || !relativeOut.startsWith(`.export${sep}`)) return;
console.warn(
`[warn] --zip-out 相对 cwd 解析:cwd=${cwd},落点=${zipPath}。`
+ '宿主在项目根 `.export/xhs-minitool.zip` 找产物;若 cwd 是 game/ 子工程,'
+ '请写 `../.export/xhs-minitool.zip`,否则会落在 game/.export/ 导致导出判失败。',
);
}
/**
* @param {{
* cwd?: string,
@@ -241,6 +258,8 @@ export function packMinitool(options = {}) {
const viteBuiltDir = resolve(cwd, options.viteBuiltDir || 'dist-xhs-minitool');
const zipPath = resolve(cwd, options.zipOut);
warnIfWorkdirRelative(cwd, zipPath, options.zipOut);
if (!existsSync(viteBuiltDir)) {
throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`);
}
@@ -82,7 +82,6 @@ const CODE_PATTERNS = [
['WARNING', 'DYNAMIC_IMPORT', '动态 import 可能产生运行时资源加载;确认目标为包内静态资源。', /\bimport\s*\(/g],
['WARNING', 'COOKIE_REVIEW', 'Cookie 只能作为本地存储;不要用于服务端登录态或鉴权。', /\bdocument\s*\.\s*cookie\b/g],
['WARNING', 'NAVIGATION_REVIEW', '检测到页面导航 API;确认不会打开外链、新窗口或其他小工具。', /\blocation\s*\.\s*href\s*=(?!=)|\b(?:location\s*\.\s*(?:assign|replace)|history\s*\.\s*(?:pushState|replaceState))\s*\(/g],
['WARNING', 'MODERN_RUNTIME_API', '使用了较新的运行时 API;须做能力检测或局部 fallback,不能作为唯一实现路径。', /\bObject\s*\.\s*hasOwn\s*\(|\bstructuredClone\s*\(|\.replaceAll\s*\(|\.at\s*\(/g],
['WARNING', 'REGEX_LOOKBEHIND', '正则使用了 lookbehind;目标内核 Chrome 61 不支持,须改写。', /\(\?<[=!]/g],
];
@@ -97,36 +96,85 @@ const ES2018_SYNTAX_PATTERNS = [
[/\b\d[\d_]*n\b/g, 'BigInt 字面量'],
];
/** 晚于 Chrome 61 的 CSS 能力;只能作为能力检测后的增强层,不能作为唯一实现。 */
const CSS_MODERN_PATTERNS = [
[/\baspect-ratio\s*:/gi, 'aspect-ratio'],
[/(?<![\w-])(?:row-|column-)?gap\s*:/gi, 'flex gap'],
[/\b(?:clamp|min|max)\s*\(/gi, 'min()/max()/clamp()'],
[/(?:^|[;{\s])(?:inset|margin-inline|margin-block|padding-inline|padding-block|border-inline|border-block)\s*[:(]/gi, '逻辑属性 / 简写'],
[/overflow\s*:\s*clip/gi, 'overflow: clip'],
[/:focus-visible/gi, ':focus-visible'],
[/:has\s*\(/gi, ':has()'],
[/@container\b/gi, '@container'],
[/\bsubgrid\b/gi, 'subgrid'],
[/@layer\b/gi, '@layer'],
[/@property\b/gi, '@property'],
[/\b\d+(?:\.\d+)?(?:dvh|svh|lvh)\b/gi, 'dvh / svh / lvh'],
[/\b(?:color-mix|oklab|oklch)\s*\(/gi, 'color-mix() / oklab() / oklch()'],
[/backdrop-filter\s*:/gi, 'backdrop-filter'],
[/text-wrap\s*:\s*balance/gi, 'text-wrap: balance'],
];
const BASE64_RE = /data:[\w.+-]+\/[\w.+-]+;base64,([A-Za-z0-9+/=]+)/g;
/**
* 把 JS 注释与字符串字面量替换成等长空白(保留换行),行号偏移不变。
*
* 语法扫描只看真正的代码:`"#e8f4ff"`、`document.querySelector('#hit')`、注释里写的 `fetch(`
* 都不该被判成语法 / API 命中。模板字面量里的 `${...}` 仍按代码处理。
*/
export function stripJsNoise(text) {
const chars = text.split('');
const blank = (index) => {
if (chars[index] !== '\n' && chars[index] !== '\r') chars[index] = ' ';
};
const n = text.length;
let i = 0;
while (i < n) {
const ch = text[i];
const next = text[i + 1];
if (ch === '/' && next === '/') {
while (i < n && text[i] !== '\n') { blank(i); i += 1; }
continue;
}
if (ch === '/' && next === '*') {
blank(i); blank(i + 1); i += 2;
while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { blank(i); i += 1; }
if (i < n) { blank(i); blank(i + 1); i += 2; }
continue;
}
if (ch === "'" || ch === '"') {
const quote = ch;
blank(i); i += 1;
while (i < n && text[i] !== quote) {
if (text[i] === '\\') { blank(i); i += 1; if (i < n) { blank(i); i += 1; } continue; }
blank(i); i += 1;
}
if (i < n) { blank(i); i += 1; }
continue;
}
if (ch === '`') {
blank(i); i += 1;
while (i < n) {
if (text[i] === '\\') { blank(i); i += 1; if (i < n) { blank(i); i += 1; } continue; }
if (text[i] === '`') { blank(i); i += 1; break; }
if (text[i] === '$' && text[i + 1] === '{') {
// 进入插值:里面的内容是真代码,保留;按花括号配平跳到闭合的 `}`。
i += 2;
let depth = 1;
while (i < n && depth > 0) {
if (text[i] === '{') depth += 1;
else if (text[i] === '}') depth -= 1;
if (depth === 0) break;
i += 1;
}
i += 1;
continue;
}
blank(i); i += 1;
}
continue;
}
i += 1;
}
return chars.join('');
}
/** JS 语法基线:ESM 语句直接判 ERROR,晚于 ES2017 的语法判 WARNING。 */
export function scanJsSyntax(path, text, collector) {
const lineAt = createLineCounter(text);
for (const match of text.matchAll(/^[ \t]*(?:import|export)\b/gm)) {
// 只在剥掉字符串 / 注释后的代码上匹配,避免 `"#e8f4ff"`、`'#hit'` 这类字面量误判。
const code = stripJsNoise(text);
for (const match of code.matchAll(/^[ \t]*(?:import|export)\b/gm)) {
collector.add('ERROR', 'ESM_SYNTAX', path, lineAt(match.index),
'最终包不得使用 ES Module 的 import / export;改为经典脚本并按依赖顺序 <script src> 引入。');
}
for (const [pattern, label] of ES2018_SYNTAX_PATTERNS) {
for (const match of text.matchAll(pattern)) {
for (const match of code.matchAll(pattern)) {
collector.add('ERROR', 'ES2018_PLUS_SYNTAX', path, lineAt(match.index),
`最终产物出现晚于 ES2017 的语法(${label});语法不兼容会在解析阶段直接失败,必须由构建链转译到 es2017 / chrome61。`);
}
@@ -151,12 +199,11 @@ export function scanBase64(path, text, collector) {
}
}
const USAGE = `用法:validate.mjs [project] [--json] [--strict]
const USAGE = `用法:validate.mjs [project] [--json]
扫描小红书小工具项目的容器兼容性问题。
[project] 产物目录,默认为 dist-xhs-minitool
--json 以 JSON 输出结果
--strict 存在 WARNING 时也返回非零状态`;
--json 以 JSON 输出结果`;
/* ------------------------------------------------------------------ */
/* 工具函数 */
@@ -366,13 +413,6 @@ export function scanCssReferences(path, text, collector, baseLine = 1) {
}
checkLocalReference(value, path, collector, line, 'CSS url()');
}
for (const [pattern, label] of CSS_MODERN_PATTERNS) {
for (const match of text.matchAll(pattern)) {
collector.add('WARNING', 'CSS_MODERN_FEATURE', path, baseLine + lineAt(match.index) - 1,
`使用了晚于 Chrome 61 的 CSS 能力(${label});需保留基线写法并用能力检测启用增强。`);
}
}
}
/* ------------------------------------------------------------------ */
@@ -956,7 +996,6 @@ export function runCli(argv, io = {}) {
args: argv,
options: {
json: { type: 'boolean' },
strict: { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
@@ -1005,7 +1044,6 @@ export function runCli(argv, io = {}) {
}
if (errors) return 1;
if (values.strict && warnings) return 2;
return 0;
}
@@ -1,5 +1,15 @@
# 决策记录
## 2026-10-06 小红书导出 validate/pack:去掉 strict 与增强层告警,扫 JS 先剥字符串/注释,zip 落点带 cwd 预警
- 背景:真实项目首轮适配反馈三处。① `validate.mjs` 的 `/#[A-Za-z_$][\w$]*/g`(class 私有字段)直接在原始文本上匹配,把 `"#e8f4ff"`、`document.querySelector('#hit')` 误判为 ES2018 语法并报 ERROR,把排查引向「构建链没转译」。② `--strict` 让 references 明确鼓励的「Chrome 61 基线 + 能力检测后的增强层」(`CSS_MODERN_FEATURE`、`MODERN_RUNTIME_API`)也判失败,想 strict 通过只能砍增强层。③ 宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析;npm 脚本挂在 `game/` 子工程时 `.export/...` 会落到 `game/.export/`,没有任何提示。
- 决策(扫描先剥噪音):`scanJsSyntax` 先过 `stripJsNoise`——把注释与字符串字面量替换成等长空白(保留换行),只对真代码跑 ESM 与 ES2018 检测;模板 `${...}` 仍按代码处理。`"#e8f4ff"`、`'#hit'`、注释里的 `obj?.c` 不再命中,真 `#count` 私有字段仍报错。
- 决策(退出码只由 ERROR 决定):删掉 `MODERN_RUNTIME_API` 与 `CSS_MODERN_PATTERNS` / `CSS_MODERN_FEATURE`,删掉 `--strict`。增强层写法交给 `references/css-compatibility.md`、`js-compatibility.md` 的能力检测要求约束,`validate.mjs` 不再把「能用但需检测」判失败。
- 决策(产物落点有确定写法与预警):`SKILL.md` 写明产物是项目根的 `.export/xhs-minitool.zip`,`game/` 子工程写 `../.export/xhs-minitool.zip`;`pack.mjs` 在 `--zip-out` 为相对路径且落点位于 `<cwd>/.export/` 时打 `[warn]`,打印 cwd 与绝对落点(`../.export/...`、绝对路径或其他相对位置不触发)。
- 影响范围:`resources/agc-skills/vite-export-xhs-minitool/{SKILL.md,scripts/{validate.mjs,pack.mjs}}`、其 `resources/agc-skills/manifest.json` 指纹(version=2026-08-26.56)、隐藏测试 `scripts/.validate.test.mjs`、`scripts/.pack.test.mjs`。宿主 `export/draft/xhs_minitool` 链路与 `xhsMinitoolInstruction.ts` 契约常量不变。
- 验证方式:`node --test scripts/.validate.test.mjs scripts/.pack.test.mjs scripts/.vite.config.xhs-minitool.test.mjs`(34 passed)、`npm run agc:skill-pack:check`、`git diff --check`、`npm run check:encoding`。
- 边界:「web 与小工具共用同一 `game/index.html`」是默认姿势,不需要另起 root;只有入口 HTML 确实不同才需要并存 recipe,本次未写,留待需要时补。
## 2026-10-06 新增 agc_install_skill_resource:Skill 附件由宿主直接落盘,不走模型正文
- 背景:`vite-export-xhs-minitool` 等审核 Skill 要把自带脚本(`scripts/validate.mjs` 约 40 KB、`pack.mjs`、`vite.config.*.mjs`)原样复制进用户项目。`agc_read_skill_resource` 的大文件正文会被模型上下文截断,重抄必然失真;原生 `cp` 在 DirectProject 只读沙箱下被审批闸门拒绝。两条路都走不通。