draftgo-cli 3.0.51 → 3.0.53
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 +32 -34
- package/package.json +2 -3
- package/resources/skill/SKILL.md +12 -18
- package/resources/skill/init/SKILL.md +5 -3
- package/resources/skill/manifest.json +3 -3
- package/resources/skill/references/checkout.md +3 -3
- package/resources/skill/references/frontend.md +1 -1
- package/resources/skill/references/mcp.md +1 -3
- package/src/cli.js +3 -1
- package/src/commandRegistry.js +2 -2
- package/src/commands/check.js +22 -7
- package/src/commands/clean.js +26 -0
- package/src/commands/help.js +19 -20
- package/src/commands/init.js +2 -3
- package/src/commands/task.js +9 -14
- package/src/commands/verify.js +90 -0
- package/src/commands/verifyUi.js +64 -21
- package/src/commands/verifyUiCompat.js +16 -0
- package/src/projectMap.js +20 -8
- package/src/skill.js +0 -9
- package/src/workspaceHealth.js +2 -2
- package/src/worktree/inlineScripts.js +99 -0
- package/src/worktree/status.js +1 -1
- package/src/commands/context.js +0 -27
- package/src/context/index.js +0 -662
package/src/commands/help.js
CHANGED
|
@@ -17,8 +17,7 @@ DraftGo Next frontend baseline: React + Vite.
|
|
|
17
17
|
Database pages may use native web technologies or bundled component libraries.
|
|
18
18
|
MCP handles live discovery and structured resources; complete page, navigation,
|
|
19
19
|
document, and custom-service bodies use checkout/commit outside MCP context.
|
|
20
|
-
The
|
|
21
|
-
it is a CLI orchestration command, not an MCP tool.
|
|
20
|
+
The installed Skill routes agents to task-specific References and MCP tools.
|
|
22
21
|
|
|
23
22
|
Usage:
|
|
24
23
|
draftgo init [<target>...] Install the DraftGo Skill. No target = detect.
|
|
@@ -37,9 +36,6 @@ Usage:
|
|
|
37
36
|
draftgo mcp test Test project, resource, and API MCP calls.
|
|
38
37
|
draftgo mcp serve Bridge local stdio to the remote /mcp endpoint.
|
|
39
38
|
|
|
40
|
-
draftgo context --task <task> Read exact task Reference sections, then reuse
|
|
41
|
-
one MCP session to query project, resources,
|
|
42
|
-
APIs, and data structures in parallel.
|
|
43
39
|
draftgo map Read remote overview/resources through MCP and
|
|
44
40
|
overlay local checkout state.
|
|
45
41
|
draftgo checkout <type> <id...> Download pages/nav/docs body + verified base.
|
|
@@ -47,6 +43,8 @@ Usage:
|
|
|
47
43
|
draftgo check [custom-services <id...>]
|
|
48
44
|
Validate all checkouts or selected custom services.
|
|
49
45
|
--remote also compares remote hash/version.
|
|
46
|
+
draftgo verify [<type> <id...>] Run unified static verification; --remote adds
|
|
47
|
+
remote comparison and --url enables UI checks.
|
|
50
48
|
draftgo diff <type> <id> Show checkout base versus local body.
|
|
51
49
|
draftgo commit <type> <id...> Validate and upload complete checked-out bodies.
|
|
52
50
|
draftgo refresh <type> <id...> Safely refresh a clean checkout from the cloud.
|
|
@@ -63,9 +61,8 @@ Usage:
|
|
|
63
61
|
draftgo conflict resolve <type> <id>
|
|
64
62
|
Mark a merged worktree file ready against the
|
|
65
63
|
preserved remote base; then check/diff/commit.
|
|
66
|
-
draftgo verify-ui <url>
|
|
67
|
-
|
|
68
|
-
draftgo clean [--dry-run|--yes] Plan or remove registered tmp/artifacts only.
|
|
64
|
+
draftgo verify-ui <url> Deprecated alias for verify --url <url> --ui always.
|
|
65
|
+
draftgo clean [--dry-run|--yes] Plan or remove all tmp and registered artifacts.
|
|
69
66
|
draftgo task <operation> [...] Track sustained work in one authoritative Task.md.
|
|
70
67
|
Operations: create, list, show, add, claim,
|
|
71
68
|
start, complete, block, reopen, accept, finish,
|
|
@@ -109,7 +106,7 @@ Important flags:
|
|
|
109
106
|
--mcp-url <url> (connect) Explicit endpoint; standard /mcp derives the
|
|
110
107
|
base URL; custom paths also need --server.
|
|
111
108
|
--token <sat> (connect) SAT for non-interactive use.
|
|
112
|
-
--timeout <ms> (connect/mcp test
|
|
109
|
+
--timeout <ms> (connect/mcp test) Network timeout.
|
|
113
110
|
--allow-offline (connect) Save only after explicitly accepting a
|
|
114
111
|
failed MCP validation.
|
|
115
112
|
--no-mcp-setup (connect) Do not write host MCP configuration.
|
|
@@ -120,8 +117,6 @@ Important flags:
|
|
|
120
117
|
--local-dev (init) Continue into local Docker setup.
|
|
121
118
|
--no-setup (init) Install the Skill without either setup flow.
|
|
122
119
|
--output json Print machine-readable output where supported.
|
|
123
|
-
--task <task> (context) frontend | data | custom-service | aihub |
|
|
124
|
-
content | project.
|
|
125
120
|
--type <type> (map) pages | nav/navigations | docs/articles.
|
|
126
121
|
--strict Treat check warnings as failures.
|
|
127
122
|
--remote (check) Compare checkout hashes/versions with remote.
|
|
@@ -137,16 +132,20 @@ Important flags:
|
|
|
137
132
|
(custom-service test) deny | mock | live; default deny.
|
|
138
133
|
--delivery <mode> (deploy) local | preview | deploy.
|
|
139
134
|
--dry-run (push) Show diffs without committing.
|
|
140
|
-
--
|
|
141
|
-
--
|
|
135
|
+
--ui <mode> (verify) auto | always | never; auto runs with --url.
|
|
136
|
+
--remote (check/verify) Compare checkout hashes with remote.
|
|
137
|
+
--viewport <mode> (verify) mobile | desktop | both.
|
|
138
|
+
--frame <mode> (verify) auto | top | all | <iframe-selector>.
|
|
139
|
+
--mobile-check <mode> (verify-ui compatibility) auto | always | never.
|
|
140
|
+
--token <mode> (verify) auto | never; auto appends the configured
|
|
142
141
|
SAT to same-origin URLs as the token query parameter.
|
|
143
|
-
--screenshot <mode> (verify
|
|
144
|
-
--browser <name> (verify
|
|
145
|
-
--browser-path <file> (verify
|
|
142
|
+
--screenshot <mode> (verify) on-failure | always | never; default never.
|
|
143
|
+
--browser <name> (verify) chromium | chrome | msedge.
|
|
144
|
+
--browser-path <file> (verify) Explicit browser executable; environment
|
|
146
145
|
fallback: DRAFTGO_BROWSER_PATH.
|
|
147
|
-
--resource <type:id> (verify
|
|
148
|
-
--selector <css> (verify
|
|
149
|
-
--width/--height <px> (verify
|
|
146
|
+
--resource <type:id> (verify compatibility) Assert one remote UI resource.
|
|
147
|
+
--selector <css> (verify) Require a visible element in top or iframe DOM.
|
|
148
|
+
--width/--height <px> (verify) Override the default 390x844 viewport.
|
|
150
149
|
|
|
151
150
|
Security:
|
|
152
151
|
.draftgo/config.json stores the server and SAT and is gitignored. Host MCP
|
|
@@ -162,10 +161,10 @@ Examples:
|
|
|
162
161
|
draftgo init codex
|
|
163
162
|
draftgo connect codex --server https://draftgo.example --token <sat>
|
|
164
163
|
draftgo mcp test
|
|
165
|
-
draftgo context --task frontend --output json
|
|
166
164
|
draftgo map --output json
|
|
167
165
|
draftgo checkout pages 42
|
|
168
166
|
draftgo check --strict
|
|
167
|
+
draftgo verify pages 42 --remote --url http://localhost:5173/example
|
|
169
168
|
draftgo diff pages 42
|
|
170
169
|
draftgo commit pages 42
|
|
171
170
|
draftgo changelog add "Complete document management and role permissions"
|
package/src/commands/init.js
CHANGED
|
@@ -4,7 +4,7 @@ const path = require('path');
|
|
|
4
4
|
const log = require('../logger');
|
|
5
5
|
const { detectTargets } = require('../detect');
|
|
6
6
|
const { resolveTargets, all } = require('../installers');
|
|
7
|
-
const { ensureRuntime,
|
|
7
|
+
const { ensureRuntime, getPackageVersion } = require('../skill');
|
|
8
8
|
const { exists } = require('../fsx');
|
|
9
9
|
const { ask } = require('../prompt');
|
|
10
10
|
|
|
@@ -65,8 +65,7 @@ async function init(projectDir, positional, flags) {
|
|
|
65
65
|
log.err(`安装未完成,失败目标:${failures.join(', ')}`);
|
|
66
66
|
return 1;
|
|
67
67
|
}
|
|
68
|
-
if (
|
|
69
|
-
if (skippedCount > 0) log.dim(' 存在未覆盖目标,未更新全局 skill 版本标记;运行 draftgo update 可统一刷新。');
|
|
68
|
+
if (skippedCount > 0) log.dim(' 存在未覆盖目标;运行 draftgo update 可统一刷新。');
|
|
70
69
|
log.dim(` CLI 版本:${getPackageVersion()}`);
|
|
71
70
|
|
|
72
71
|
// 3) Environment check (advisory).
|
package/src/commands/task.js
CHANGED
|
@@ -75,7 +75,7 @@ function lines(value, fallback = '- 暂无。') {
|
|
|
75
75
|
|
|
76
76
|
function template(task) {
|
|
77
77
|
return `# ${task.title}\n\n`+
|
|
78
|
-
`- 状态:active\n- 创建时间:${task.created_at}\n- 更新时间:${task.updated_at}\n
|
|
78
|
+
`- 状态:active\n- 创建时间:${task.created_at}\n- 更新时间:${task.updated_at}\n\n`+
|
|
79
79
|
`## 用户原始需求\n\n${task.original_requirement}\n\n`+
|
|
80
80
|
`## 明确后的需求\n\n${task.clarified_requirement}\n\n`+
|
|
81
81
|
`## 预计效果\n\n${lines(task.expected_effect)}\n\n`+
|
|
@@ -134,7 +134,7 @@ function parseMarkdown(file, id) {
|
|
|
134
134
|
const acceptance = parseChecklist(section(body, '最终验收'), true);
|
|
135
135
|
return {
|
|
136
136
|
schema_version: 2, format: 'markdown', id, title, status: metadata(body, '状态') || 'active',
|
|
137
|
-
created_at: metadata(body, '创建时间'), updated_at: metadata(body, '更新时间'),
|
|
137
|
+
created_at: metadata(body, '创建时间'), updated_at: metadata(body, '更新时间'),
|
|
138
138
|
original_requirement: section(body, '用户原始需求'), clarified_requirement: section(body, '明确后的需求'),
|
|
139
139
|
expected_effect: section(body, '预计效果'), scope: section(body, '本次范围'), out_of_scope: section(body, '不在本次范围'),
|
|
140
140
|
decisions: section(body, '关键决策'), blockers: section(body, '阻塞问题'), items, acceptance,
|
|
@@ -191,12 +191,6 @@ function print(flags, value) {
|
|
|
191
191
|
if (flags.output === 'json') console.log(JSON.stringify(Array.isArray(value) ? value.map(publicTask) : publicTask(value), null, 2));
|
|
192
192
|
}
|
|
193
193
|
function taskActor(flags) { return String(flags.owner || flags.assignee || process.env.DRAFTGO_AGENT_ID || '').trim(); }
|
|
194
|
-
function requireOwner(task, flags, operation) {
|
|
195
|
-
if (!task.owner) return;
|
|
196
|
-
const actor = taskActor(flags);
|
|
197
|
-
if (!actor) throw new Error(`Task ${task.id} ${operation} requires owner identity ${task.owner}.`);
|
|
198
|
-
if (actor !== task.owner) throw new Error(`Task ${task.id} ${operation} can only be performed by owner ${task.owner}.`);
|
|
199
|
-
}
|
|
200
194
|
function assertModern(task, operation) {
|
|
201
195
|
if (task.format === 'legacy') throw new Error(`Legacy Task ${task.id} is read-only; run task migrate ${task.id} before ${operation}.`);
|
|
202
196
|
}
|
|
@@ -245,7 +239,7 @@ function create(projectDir, positional, flags) {
|
|
|
245
239
|
if (!clarified) throw new Error('task create requires --clarified with the executable requirement.');
|
|
246
240
|
if (!expected) throw new Error('task create requires --expected-effect.');
|
|
247
241
|
const id = uniqueTaskId(projectDir, title, flags); const now = new Date().toISOString();
|
|
248
|
-
const task = { id, title, created_at: now, updated_at: now,
|
|
242
|
+
const task = { id, title, created_at: now, updated_at: now, original_requirement: original, clarified_requirement: clarified,
|
|
249
243
|
expected_effect: expected, scope: field(flags, ['scope']), out_of_scope: field(flags, ['out-of-scope']) };
|
|
250
244
|
fs.mkdirSync(taskDir(projectDir, id), { recursive: true }); atomicWrite(markdownFile(projectDir, id), template(task));
|
|
251
245
|
const loaded = loadTask(projectDir, id); print(flags, loaded); if (flags.output !== 'json') log.ok(`Created task ${id}`); return 0;
|
|
@@ -279,7 +273,7 @@ function nextItemId(items) {
|
|
|
279
273
|
function add(projectDir, taskId, positional, flags) {
|
|
280
274
|
const resolved = resolveTaskId(projectDir, taskId);
|
|
281
275
|
return withLock(projectDir, resolved, (before) => {
|
|
282
|
-
const task = loadTask(projectDir, resolved); assertModern(task, 'add');
|
|
276
|
+
const task = loadTask(projectDir, resolved); assertModern(task, 'add');
|
|
283
277
|
const title = field(flags, ['title'], positional.join(' ')); const content = field(flags, ['content']); const acceptance = field(flags, ['acceptance']);
|
|
284
278
|
const owner = field(flags, ['assignee'], taskActor(flags));
|
|
285
279
|
if (!title) throw new Error('task add requires a title or --title.');
|
|
@@ -343,7 +337,7 @@ function accept(projectDir, taskId, positional, flags) {
|
|
|
343
337
|
const resolved = resolveTaskId(projectDir, taskId); const title = field(flags, ['item'], positional.join(' '));
|
|
344
338
|
if (!title) throw new Error('task accept requires a final acceptance item title.');
|
|
345
339
|
return withLock(projectDir, resolved, (before) => {
|
|
346
|
-
const task = loadTask(projectDir, resolved); assertModern(task, 'accept');
|
|
340
|
+
const task = loadTask(projectDir, resolved); assertModern(task, 'accept');
|
|
347
341
|
const evidence = field(flags, ['evidence']); if (!evidence) throw new Error('task accept requires --evidence.');
|
|
348
342
|
const body = touch(task, updateAcceptanceBody(task.body, title, true, evidence), 'active'); saveBody(task, body, before);
|
|
349
343
|
const updated = loadTask(projectDir, resolved); print(flags, updated.acceptance.find((entry) => entry.title === title));
|
|
@@ -354,7 +348,7 @@ function accept(projectDir, taskId, positional, flags) {
|
|
|
354
348
|
function finish(projectDir, taskId, flags) {
|
|
355
349
|
const resolved = resolveTaskId(projectDir, taskId);
|
|
356
350
|
return withLock(projectDir, resolved, (before) => {
|
|
357
|
-
const task = loadTask(projectDir, resolved); assertModern(task, 'finish');
|
|
351
|
+
const task = loadTask(projectDir, resolved); assertModern(task, 'finish');
|
|
358
352
|
if (task.items.some((item) => !item.completed)) throw new Error(`Cannot finish ${task.id}; development items remain incomplete.`);
|
|
359
353
|
const pending = task.acceptance.filter((item) => !item.completed);
|
|
360
354
|
if (pending.length) throw new Error(`Cannot finish ${task.id}; final acceptance remains incomplete: ${pending.map((item) => item.title).join(', ')}.`);
|
|
@@ -379,11 +373,12 @@ function migrate(projectDir, taskId, flags) {
|
|
|
379
373
|
const items = legacyItemDetails(projectDir, task); const now = new Date().toISOString();
|
|
380
374
|
const original = field(flags, ['original'], task.title || resolved); const clarified = field(flags, ['clarified'], original);
|
|
381
375
|
const expected = field(flags, ['expected-effect'], '保留历史任务的目标、进度和验收证据,并使用单文件继续维护。');
|
|
382
|
-
const
|
|
376
|
+
const defaultOwner = task.owner || taskActor(flags);
|
|
377
|
+
const modern = { title: task.title || resolved, created_at: task.created_at || now, updated_at: now, original_requirement: original,
|
|
383
378
|
clarified_requirement: clarified, expected_effect: expected, scope: '', out_of_scope: '' };
|
|
384
379
|
let body = template(modern); const blocks = items.map((item, index) => {
|
|
385
380
|
const id = /^DG-\d{3}$/.test(item.id) ? item.id : `DG-${String(index + 1).padStart(3, '0')}`;
|
|
386
|
-
const owner = item.owner ||
|
|
381
|
+
const owner = item.owner || defaultOwner || 'unassigned'; const evidence = item.evidence.join('; ') || 'Legacy task state migrated.';
|
|
387
382
|
return `- [${item.status === 'completed' ? 'x' : ' '}] ${id} ${item.title}\n - 内容:从历史 Task 迁移。\n - 负责人:${owner}\n - 验收:保留历史工作项状态与证据。\n - 状态:${item.status || 'pending'}${item.status === 'completed' ? `\n - 完成结果:${evidence}` : ''}`;
|
|
388
383
|
});
|
|
389
384
|
body = replaceSection(body, '开发清单', blocks.length ? blocks.join('\n\n') : '暂无。');
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const log = require('../logger');
|
|
4
|
+
const check = require('./check');
|
|
5
|
+
const verifyUi = require('./verifyUi');
|
|
6
|
+
const { canonicalResourceType } = require('../worktree/types');
|
|
7
|
+
|
|
8
|
+
function mode(value, fallback, flag) {
|
|
9
|
+
const normalized = String(value == null ? fallback : value).toLowerCase();
|
|
10
|
+
if (!['auto', 'always', 'never'].includes(normalized)) throw new Error(`${flag} must be auto, always, or never.`);
|
|
11
|
+
return normalized;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resources(positional) {
|
|
15
|
+
if (!positional.length) return [];
|
|
16
|
+
const [rawType, ...ids] = positional;
|
|
17
|
+
if (!ids.length) throw new Error('Usage: draftgo verify [<pages|nav|docs> <id...>] [--url <url>]');
|
|
18
|
+
const resourceType = canonicalResourceType(rawType);
|
|
19
|
+
return ids.map((resourceId) => ({ resourceType, resourceId: String(resourceId) }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function viewports(flags) {
|
|
23
|
+
const value = String(flags.viewport || '').toLowerCase();
|
|
24
|
+
if (!value) return [{ name: 'custom', width: flags.width, height: flags.height }];
|
|
25
|
+
if (value === 'mobile') return [{ name: 'mobile', width: 390, height: 844 }];
|
|
26
|
+
if (value === 'desktop') return [{ name: 'desktop', width: 1440, height: 900 }];
|
|
27
|
+
if (value === 'both') return [
|
|
28
|
+
{ name: 'mobile', width: 390, height: 844 },
|
|
29
|
+
{ name: 'desktop', width: 1440, height: 900 },
|
|
30
|
+
];
|
|
31
|
+
throw new Error('--viewport must be mobile, desktop, or both.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function verify(projectDir, positional = [], flags = {}) {
|
|
35
|
+
let selected;
|
|
36
|
+
let uiMode;
|
|
37
|
+
let remoteMode;
|
|
38
|
+
try {
|
|
39
|
+
selected = resources(positional);
|
|
40
|
+
uiMode = mode(flags.ui, 'auto', '--ui');
|
|
41
|
+
remoteMode = flags.remote ? 'always' : 'never';
|
|
42
|
+
} catch (error) {
|
|
43
|
+
log.err(error.message);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
log.title('draftgo verify');
|
|
48
|
+
const checkCode = await check(projectDir, [], {
|
|
49
|
+
strict: flags.strict,
|
|
50
|
+
remote: remoteMode === 'always',
|
|
51
|
+
resourceKeys: selected.length
|
|
52
|
+
? selected.map((entry) => `${entry.resourceType}:${entry.resourceId}`)
|
|
53
|
+
: null,
|
|
54
|
+
});
|
|
55
|
+
if (checkCode !== 0) return checkCode;
|
|
56
|
+
|
|
57
|
+
const url = String(flags.url || '').trim();
|
|
58
|
+
const runUi = uiMode === 'always' || (uiMode === 'auto' && Boolean(url));
|
|
59
|
+
if (!runUi) {
|
|
60
|
+
if (uiMode === 'always' && !url) {
|
|
61
|
+
log.err('--ui always requires --url <http://localhost:port/path>.');
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
log.ok('Unified verification passed (UI skipped).');
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
if (selected.length > 1) {
|
|
68
|
+
log.err('One --url can assert at most one resource. Run verify once per route for UI validation.');
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let targets;
|
|
73
|
+
try { targets = viewports(flags); }
|
|
74
|
+
catch (error) { log.err(error.message); return 1; }
|
|
75
|
+
for (const viewport of targets) {
|
|
76
|
+
const uiFlags = { ...flags, url, width: viewport.width, height: viewport.height, 'mobile-check': 'always' };
|
|
77
|
+
if (remoteMode === 'always' && selected.length === 1 && !uiFlags.resource) {
|
|
78
|
+
uiFlags.resource = `${selected[0].resourceType}:${selected[0].resourceId}`;
|
|
79
|
+
}
|
|
80
|
+
const code = await verifyUi(projectDir, [url], uiFlags);
|
|
81
|
+
if (code !== 0) return code;
|
|
82
|
+
}
|
|
83
|
+
log.ok(`Unified verification passed (${targets.map((item) => item.name).join(', ')}).`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = verify;
|
|
88
|
+
module.exports.mode = mode;
|
|
89
|
+
module.exports.resources = resources;
|
|
90
|
+
module.exports.viewports = viewports;
|
package/src/commands/verifyUi.js
CHANGED
|
@@ -163,6 +163,51 @@ function parseResourceSpec(value) {
|
|
|
163
163
|
return { resourceType: canonicalResourceType(raw.slice(0, separator)), resourceId: raw.slice(separator + 1) };
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
async function selectFrames(page, mode = 'auto') {
|
|
167
|
+
const top = page.mainFrame ? page.mainFrame() : page;
|
|
168
|
+
const selected = [{ frame: top, label: 'top' }];
|
|
169
|
+
if (mode === 'top') return selected;
|
|
170
|
+
if (!['auto', 'all'].includes(mode)) {
|
|
171
|
+
const handle = await page.locator(mode).first().elementHandle().catch(() => null);
|
|
172
|
+
const frame = handle && await handle.contentFrame().catch(() => null);
|
|
173
|
+
if (!frame) throw new Error(`Frame not found: ${mode}`);
|
|
174
|
+
return [{ frame, label: `selector:${mode}` }];
|
|
175
|
+
}
|
|
176
|
+
const frames = page.frames ? page.frames() : [];
|
|
177
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
178
|
+
const frame = frames[index];
|
|
179
|
+
if (frame === top) continue;
|
|
180
|
+
let visible = true;
|
|
181
|
+
if (mode === 'auto') {
|
|
182
|
+
const element = await frame.frameElement().catch(() => null);
|
|
183
|
+
visible = Boolean(element && await element.isVisible().catch(() => false));
|
|
184
|
+
}
|
|
185
|
+
if (visible) selected.push({
|
|
186
|
+
frame,
|
|
187
|
+
label: `frame:${frame.name && frame.name() ? frame.name() : index}`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return selected;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function inspectFrame(frame) {
|
|
194
|
+
return frame.evaluate(() => {
|
|
195
|
+
const body = document.body;
|
|
196
|
+
const root = document.documentElement;
|
|
197
|
+
return {
|
|
198
|
+
bodyTextLength: body ? (body.innerText || '').trim().length : 0,
|
|
199
|
+
bodyHeight: body ? body.getBoundingClientRect().height : 0,
|
|
200
|
+
hasVisibleMedia: body ? Array.from(body.querySelectorAll('img,svg,canvas,video,iframe,input,button')).some((element) => {
|
|
201
|
+
const rect = element.getBoundingClientRect();
|
|
202
|
+
return rect.width > 1 && rect.height > 1;
|
|
203
|
+
}) : false,
|
|
204
|
+
horizontalOverflow: root.scrollWidth > window.innerWidth + 1,
|
|
205
|
+
scrollWidth: root.scrollWidth,
|
|
206
|
+
viewportWidth: window.innerWidth,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
166
211
|
async function verifyRemoteResource(projectDir, spec) {
|
|
167
212
|
const { resourceType, resourceId } = parseResourceSpec(spec);
|
|
168
213
|
const entry = getEntry(loadManifest(projectDir), resourceType, resourceId);
|
|
@@ -257,32 +302,28 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
257
302
|
|
|
258
303
|
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
259
304
|
if (waitMs) await page.waitForTimeout(waitMs);
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
title: document.title,
|
|
265
|
-
bodyTextLength: body ? (body.innerText || '').trim().length : 0,
|
|
266
|
-
bodyHeight: body ? body.getBoundingClientRect().height : 0,
|
|
267
|
-
hasVisibleMedia: body ? Array.from(body.querySelectorAll('img,svg,canvas,video,iframe,input,button')).some((element) => {
|
|
268
|
-
const rect = element.getBoundingClientRect();
|
|
269
|
-
return rect.width > 1 && rect.height > 1;
|
|
270
|
-
}) : false,
|
|
271
|
-
horizontalOverflow: root.scrollWidth > window.innerWidth + 1,
|
|
272
|
-
scrollWidth: root.scrollWidth,
|
|
273
|
-
viewportWidth: window.innerWidth,
|
|
274
|
-
};
|
|
275
|
-
});
|
|
305
|
+
const frames = await selectFrames(page, String(flags.frame || 'auto'));
|
|
306
|
+
const states = [];
|
|
307
|
+
for (const item of frames) states.push({ ...item, state: await inspectFrame(item.frame) });
|
|
276
308
|
|
|
277
309
|
const issues = [];
|
|
278
310
|
if (response && response.status() >= 400) issues.push(`页面返回 HTTP ${response.status()}`);
|
|
279
|
-
if (!state.bodyHeight
|
|
280
|
-
|
|
311
|
+
if (!states.some(({ state }) => state.bodyHeight && (state.bodyTextLength || state.hasVisibleMedia))) issues.push('页面疑似空白');
|
|
312
|
+
for (const { label, state } of states) {
|
|
313
|
+
if (state.horizontalOverflow) issues.push(`${label} 横向溢出:scrollWidth=${state.scrollWidth}, viewport=${state.viewportWidth}`);
|
|
314
|
+
}
|
|
281
315
|
if (consoleErrors.length) issues.push(`console error ${consoleErrors.length} 条`);
|
|
282
316
|
if (pageErrors.length) issues.push(`page error ${pageErrors.length} 条`);
|
|
283
317
|
if (flags.selector) {
|
|
284
|
-
|
|
285
|
-
|
|
318
|
+
let match = null;
|
|
319
|
+
for (const item of frames) {
|
|
320
|
+
if (await item.frame.locator(String(flags.selector)).first().isVisible().catch(() => false)) {
|
|
321
|
+
match = item.label;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!match) issues.push(`关键元素在已检查 frame 中不可见:${flags.selector}`);
|
|
326
|
+
else log.info(`Selector ${flags.selector}: visible in ${match}`);
|
|
286
327
|
}
|
|
287
328
|
|
|
288
329
|
const shouldScreenshot = screenshotMode === 'always' || (screenshotMode === 'on-failure' && issues.length > 0);
|
|
@@ -303,7 +344,7 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
303
344
|
return 1;
|
|
304
345
|
}
|
|
305
346
|
|
|
306
|
-
log.ok(`UI smoke check 通过:${width}x${height},${decision.reason}`);
|
|
347
|
+
log.ok(`UI smoke check 通过:${width}x${height},${frames.length} frame(s),${decision.reason}`);
|
|
307
348
|
if (screenshotPath) log.info(`截图:${screenshotPath}`);
|
|
308
349
|
return 0;
|
|
309
350
|
} catch (err) {
|
|
@@ -324,3 +365,5 @@ module.exports.executableCandidates = executableCandidates;
|
|
|
324
365
|
module.exports.playwrightCacheCandidates = playwrightCacheCandidates;
|
|
325
366
|
module.exports.parseResourceSpec = parseResourceSpec;
|
|
326
367
|
module.exports.verifyRemoteResource = verifyRemoteResource;
|
|
368
|
+
module.exports.selectFrames = selectFrames;
|
|
369
|
+
module.exports.inspectFrame = inspectFrame;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const log = require('../logger');
|
|
4
|
+
const verify = require('./verify');
|
|
5
|
+
|
|
6
|
+
async function verifyUiCompat(projectDir, positional = [], flags = {}) {
|
|
7
|
+
const url = String(flags.url || positional[0] || '').trim();
|
|
8
|
+
log.warn('`draftgo verify-ui` is deprecated; use `draftgo verify --url <url> --ui always`.');
|
|
9
|
+
return verify(projectDir, [], {
|
|
10
|
+
...flags,
|
|
11
|
+
url,
|
|
12
|
+
ui: 'always',
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = verifyUiCompat;
|
package/src/projectMap.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const parse5 = require('parse5');
|
|
7
7
|
const { loadManifest, absolutePath } = require('./worktree/manifest');
|
|
8
8
|
const { mediaType } = require('./worktree/types');
|
|
9
|
+
const { validateInlineScripts } = require('./worktree/inlineScripts');
|
|
9
10
|
|
|
10
11
|
function normalizeRoute(route) {
|
|
11
12
|
if (!route || typeof route !== 'string') return '';
|
|
@@ -121,7 +122,7 @@ function worktreeFiles(projectDir, directory) {
|
|
|
121
122
|
.map((entry) => path.join(root, entry.name));
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
function analyzeProject(projectDir) {
|
|
125
|
+
function analyzeProject(projectDir, options = {}) {
|
|
125
126
|
const map = buildProjectMap(projectDir);
|
|
126
127
|
const errors = [];
|
|
127
128
|
const warnings = [];
|
|
@@ -132,7 +133,12 @@ function analyzeProject(projectDir) {
|
|
|
132
133
|
};
|
|
133
134
|
const registered = new Set();
|
|
134
135
|
|
|
135
|
-
|
|
136
|
+
const resourceKeys = options.resourceKeys ? new Set(options.resourceKeys) : null;
|
|
137
|
+
const selectedCheckouts = resourceKeys
|
|
138
|
+
? map.checkouts.filter((entry) => resourceKeys.has(`${entry.resource_type}:${entry.resource_id}`))
|
|
139
|
+
: map.checkouts;
|
|
140
|
+
|
|
141
|
+
for (const entry of selectedCheckouts) {
|
|
136
142
|
let local;
|
|
137
143
|
let base;
|
|
138
144
|
try {
|
|
@@ -176,18 +182,24 @@ function analyzeProject(projectDir) {
|
|
|
176
182
|
const position = issue.line ? ` at ${issue.line}:${issue.column || 1}` : '';
|
|
177
183
|
addWarning('DG-HTML-001', 'high', `${entry.resource_type} ${entry.resource_id}: ${issue.code}${position}.`);
|
|
178
184
|
}
|
|
185
|
+
for (const issue of validateInlineScripts(source).slice(0, 20)) {
|
|
186
|
+
errors.push(`${entry.resource_type} ${entry.resource_id}: inline script #${issue.script} `
|
|
187
|
+
+ `(${issue.script_type}) ${issue.message} at ${entry.local_path}:${issue.line}:${issue.column}.`);
|
|
188
|
+
}
|
|
179
189
|
}
|
|
180
190
|
}
|
|
181
191
|
|
|
182
|
-
|
|
183
|
-
for (const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
if (!resourceKeys) {
|
|
193
|
+
for (const directory of ['pages', 'navigations', 'docs']) {
|
|
194
|
+
for (const file of worktreeFiles(projectDir, directory)) {
|
|
195
|
+
if (!registered.has(path.resolve(file).toLowerCase())) {
|
|
196
|
+
const relative = path.relative(projectDir, file).replace(/\\/g, '/');
|
|
197
|
+
addWarning('DG-WORKTREE-ORPHAN', 'high', `Unregistered worktree file: ${relative}.`);
|
|
198
|
+
}
|
|
187
199
|
}
|
|
188
200
|
}
|
|
189
201
|
}
|
|
190
|
-
for (const page of map.pages) {
|
|
202
|
+
for (const page of map.pages.filter((entry) => !resourceKeys || resourceKeys.has(`pages:${entry.resource_id}`))) {
|
|
191
203
|
if (!page.route || page.route === '/' || page.area === 'system') continue;
|
|
192
204
|
const sources = map.routeRefs[normalizeRoute(page.route)] || [];
|
|
193
205
|
if (!sources.some((source) => source.startsWith('navigations:'))) {
|
package/src/skill.js
CHANGED
|
@@ -187,15 +187,6 @@ function validateRenderedSkill(assetDir) {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
function ensureRuntime(projectDir) {
|
|
190
|
-
const dg = paths.dgDir(projectDir);
|
|
191
|
-
ensureDir(dg);
|
|
192
|
-
for (const sub of ['Task', 'lessons']) {
|
|
193
|
-
ensureDir(path.join(dg, sub));
|
|
194
|
-
}
|
|
195
|
-
const changelog = path.join(dg, 'changelog.md');
|
|
196
|
-
if (!exists(changelog)) {
|
|
197
|
-
writeText(changelog, '');
|
|
198
|
-
}
|
|
199
190
|
appendGitignoreLine(projectDir, '.draftgo/config.json');
|
|
200
191
|
appendGitignoreLine(projectDir, '.draftgo/token');
|
|
201
192
|
appendGitignoreLine(projectDir, '.draftgo/worktree/');
|
package/src/workspaceHealth.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const runtime = require('./runtimeFiles');
|
|
6
6
|
|
|
7
|
-
const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', 'Task/', 'lessons/', 'config.json', 'changelog.md', 'story.yaml', '.version', 'runtime-manifest.json'];
|
|
7
|
+
const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', 'Task/', 'lessons/', 'tmp/', 'config.json', 'changelog.md', 'story.yaml', '.version', 'runtime-manifest.json'];
|
|
8
8
|
function workspaceHealth(projectDir) {
|
|
9
9
|
const root = runtime.draftgoRoot(projectDir); const registered = new Set(runtime.load(projectDir).entries.map((entry) => String(entry.path).replace(/\\/g, '/')));
|
|
10
10
|
const summary = { managed_files: 0, unknown_files: 0, temporary_files: 0, artifact_files: 0, total_bytes: 0, reclaimable_bytes: 0, warnings: [] };
|
|
@@ -22,7 +22,7 @@ function workspaceHealth(projectDir) {
|
|
|
22
22
|
if (managed) summary.managed_files += 1; else summary.unknown_files += 1;
|
|
23
23
|
if (relative.startsWith('tmp/')) summary.temporary_files += 1;
|
|
24
24
|
if (relative.startsWith('artifacts/')) summary.artifact_files += 1;
|
|
25
|
-
if (registered.has(relative)) summary.reclaimable_bytes += size;
|
|
25
|
+
if (registered.has(relative) || relative.startsWith('tmp/')) summary.reclaimable_bytes += size;
|
|
26
26
|
if (!managed && (/\.exe$/i.test(relative) || (/^[^/]+\.(go|json)$/i.test(relative) && !['config.json', 'runtime-manifest.json'].includes(relative)))) summary.warnings.push({ code: 'DG-WORKSPACE-UNKNOWN-FILE', path: relative, size });
|
|
27
27
|
if (relative.startsWith('artifacts/') && !registered.has(relative)) summary.warnings.push({ code: 'DG-WORKSPACE-UNREGISTERED-ARTIFACT', path: relative, size });
|
|
28
28
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const vm = require('vm');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const parse5 = require('parse5');
|
|
6
|
+
|
|
7
|
+
const DATA_SCRIPT_TYPES = new Set([
|
|
8
|
+
'application/json',
|
|
9
|
+
'application/ld+json',
|
|
10
|
+
'importmap',
|
|
11
|
+
'speculationrules',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function attribute(node, name) {
|
|
15
|
+
const item = (node.attrs || []).find((candidate) => candidate.name.toLowerCase() === name);
|
|
16
|
+
return item ? item.value : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function scriptSource(node) {
|
|
20
|
+
return (node.childNodes || [])
|
|
21
|
+
.filter((child) => child.nodeName === '#text')
|
|
22
|
+
.map((child) => child.value || '')
|
|
23
|
+
.join('');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectInlineScripts(html) {
|
|
27
|
+
const document = parse5.parse(String(html || ''), { sourceCodeLocationInfo: true });
|
|
28
|
+
const scripts = [];
|
|
29
|
+
const visit = (node) => {
|
|
30
|
+
if (node.tagName === 'script' && !attribute(node, 'src')) {
|
|
31
|
+
const type = attribute(node, 'type').trim().toLowerCase();
|
|
32
|
+
if (!DATA_SCRIPT_TYPES.has(type) && (!type || type === 'module' || /(?:java|ecma)script/.test(type))) {
|
|
33
|
+
const location = node.sourceCodeLocation || {};
|
|
34
|
+
const startTag = location.startTag || {};
|
|
35
|
+
scripts.push({
|
|
36
|
+
index: scripts.length + 1,
|
|
37
|
+
type: type === 'module' ? 'module' : 'classic',
|
|
38
|
+
source: scriptSource(node),
|
|
39
|
+
startLine: startTag.endLine || location.startLine || 1,
|
|
40
|
+
startColumn: startTag.endCol || 1,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const child of node.childNodes || []) visit(child);
|
|
45
|
+
if (node.content) visit(node.content);
|
|
46
|
+
};
|
|
47
|
+
visit(document);
|
|
48
|
+
return scripts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function syntaxPosition(message) {
|
|
52
|
+
const line = String(message || '').match(/(?:\[stdin\]|inline-script):(\d+)(?::(\d+))?/);
|
|
53
|
+
if (line) return { line: Number(line[1]), column: Number(line[2] || 1) };
|
|
54
|
+
const caretLines = String(message || '').split(/\r?\n/);
|
|
55
|
+
const caretIndex = caretLines.findIndex((value) => /^\s*\^/.test(value));
|
|
56
|
+
return { line: null, column: caretIndex > 0 ? caretLines[caretIndex].indexOf('^') + 1 : null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function conciseMessage(error) {
|
|
60
|
+
const lines = String(error && (error.stderr || error.message) || error || '').split(/\r?\n/);
|
|
61
|
+
return lines.find((line) => /^SyntaxError:/.test(line))
|
|
62
|
+
|| lines.find((line) => line.trim() && !/^\s*at\s/.test(line))
|
|
63
|
+
|| 'Invalid JavaScript syntax';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateInlineScripts(html) {
|
|
67
|
+
const issues = [];
|
|
68
|
+
for (const script of collectInlineScripts(html)) {
|
|
69
|
+
if (!script.source.trim()) continue;
|
|
70
|
+
let failure = null;
|
|
71
|
+
if (script.type === 'module') {
|
|
72
|
+
const result = spawnSync(process.execPath, ['--check', '--input-type=module'], {
|
|
73
|
+
input: script.source,
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
});
|
|
77
|
+
if (result.error || result.status !== 0) failure = { message: result.stderr || result.stdout || result.error.message };
|
|
78
|
+
} else {
|
|
79
|
+
try { new vm.Script(script.source, { filename: 'inline-script' }); }
|
|
80
|
+
catch (error) { failure = error; }
|
|
81
|
+
}
|
|
82
|
+
if (!failure) continue;
|
|
83
|
+
const diagnostic = failure.stack || failure.message;
|
|
84
|
+
const position = syntaxPosition(diagnostic);
|
|
85
|
+
issues.push({
|
|
86
|
+
code: 'DG-JS-001',
|
|
87
|
+
script: script.index,
|
|
88
|
+
script_type: script.type,
|
|
89
|
+
line: position.line ? script.startLine + position.line - 1 : script.startLine,
|
|
90
|
+
column: position.column
|
|
91
|
+
? position.column + (position.line === 1 ? script.startColumn - 1 : 0)
|
|
92
|
+
: script.startColumn,
|
|
93
|
+
message: conciseMessage(failure),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return issues;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { DATA_SCRIPT_TYPES, collectInlineScripts, validateInlineScripts };
|
package/src/worktree/status.js
CHANGED
|
@@ -101,7 +101,7 @@ async function inspectRemoteCheckouts(projectDir, options = {}) {
|
|
|
101
101
|
const session = options.backend && typeof options.backend.resolveMetadata === 'function'
|
|
102
102
|
? { client: options.client || {}, tools: options.tools || [] }
|
|
103
103
|
: await openMetadataSession(config, options);
|
|
104
|
-
const entries = Object.values(manifest.entries);
|
|
104
|
+
const entries = options.entries || Object.values(manifest.entries);
|
|
105
105
|
return Promise.all(entries.map(async (entry) => {
|
|
106
106
|
const remote = await backend.resolveMetadata(config, entry.resource_type, entry.resource_id, {
|
|
107
107
|
...options,
|