@anchorlock/cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/bin/anchor.js +15 -0
- package/ci/check-contract.github.yml +25 -0
- package/ci/check-contract.gitlab.yml +12 -0
- package/docs/usage.md +320 -0
- package/lib/cli.js +724 -0
- package/lib/demo.js +48 -0
- package/lib/engine.js +36 -0
- package/lib/install-agent.js +46 -0
- package/lib/term.js +45 -0
- package/package.json +44 -4
- package/public/index.html +1985 -0
- package/skills/anchorlock/SKILL.md +78 -0
package/lib/cli.js
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const { loadEngine } = require('./engine');
|
|
7
|
+
const term = require('./term');
|
|
8
|
+
|
|
9
|
+
const PKG = require('../package.json');
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const args = { _: [] };
|
|
13
|
+
for (let i = 2; i < argv.length; i++) {
|
|
14
|
+
const a = argv[i];
|
|
15
|
+
if (a === '--contract') args.contract = argv[++i];
|
|
16
|
+
else if (a === '--src') args.src = argv[++i];
|
|
17
|
+
else if (a === '--hash') args.hash = argv[++i];
|
|
18
|
+
else if (a === '--require-hash') args.requireHash = true;
|
|
19
|
+
else if (a === '--cache-dir') args.cacheDir = argv[++i];
|
|
20
|
+
else if (a === '--from-code') {
|
|
21
|
+
args.fromCode = true;
|
|
22
|
+
if (argv[i + 1] && !String(argv[i + 1]).startsWith('-')) args.src = argv[++i];
|
|
23
|
+
} else if (a === '--draft') args.draft = true;
|
|
24
|
+
else if (a === '--from') args.from = argv[++i];
|
|
25
|
+
else if (a === '--url') args.url = argv[++i];
|
|
26
|
+
else if (a === '--tag') args.tag = argv[++i];
|
|
27
|
+
else if (a === '--source') args.source = argv[++i];
|
|
28
|
+
else if (a === '--change') args.change = argv[++i];
|
|
29
|
+
else if (a === '--init') args.init = true;
|
|
30
|
+
else if (a === '--out' || a === '-o') args.out = argv[++i];
|
|
31
|
+
else if (a === '--force') args.force = true;
|
|
32
|
+
else if (a === '--dry-run') args.dryRun = true;
|
|
33
|
+
else if (a === '--format') args.format = argv[++i];
|
|
34
|
+
else if (a === '--json') args.format = 'json';
|
|
35
|
+
else if (a === '--no-color') args.noColor = true;
|
|
36
|
+
else if (a === '--drift') args.drift = true;
|
|
37
|
+
else if (a === '--changed') args.changed = true;
|
|
38
|
+
else if (a === '--strict') args.strict = true;
|
|
39
|
+
else if (a === '-h' || a === '--help') args.help = true;
|
|
40
|
+
else if (a === '-V' || a === '--version') args.version = true;
|
|
41
|
+
else if (a.startsWith('-')) throw new Error('unknown flag: ' + a);
|
|
42
|
+
else args._.push(a);
|
|
43
|
+
}
|
|
44
|
+
return args;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printHelp() {
|
|
48
|
+
const lines = [
|
|
49
|
+
'Anchorlock keep AI-generated code faithful to your contract',
|
|
50
|
+
'',
|
|
51
|
+
'Usage:',
|
|
52
|
+
' 消费仓对照钉死的 OpenAPI(主路径):',
|
|
53
|
+
' anchor init # 安装 Agent skill(不写字段名单)',
|
|
54
|
+
' anchor pin --from <openapi.json> --url <download-url> --tag <vX.Y.Z> [--change <需求号>]',
|
|
55
|
+
' anchor check --contract <pin.json> --src src --strict --require-hash',
|
|
56
|
+
' # 投影给抛锚用(这一步不是抛锚):',
|
|
57
|
+
' anchor constraints --from <pin.json> [-o constraints.json]',
|
|
58
|
+
'',
|
|
59
|
+
' 第二回合:后端发了新 tag(不要往 pin 里手加字段,整份换):',
|
|
60
|
+
' anchor repin --from <new-openapi.json> --tag <vX.Y.Z> [--change <需求号>]',
|
|
61
|
+
' anchor check --contract <pin.json> --src src --strict --require-hash',
|
|
62
|
+
'',
|
|
63
|
+
' 没有 OpenAPI:一处起草,人确认后再 check:',
|
|
64
|
+
' anchor init --from-code <one-module> [-o constraints.json]',
|
|
65
|
+
' anchor check --contract constraints.json --src src',
|
|
66
|
+
'',
|
|
67
|
+
' --src 扫描目录或文件(CI 钉死 src/;不要让人填某个源文件)',
|
|
68
|
+
' --changed 只扫相对 HEAD 改过 / 未跟踪的源文件(git)',
|
|
69
|
+
' --change 把这次换版关联到需求号,写进 pin.json,check 时一并显示',
|
|
70
|
+
' --drift 只拦近义词碰撞(默认)',
|
|
71
|
+
' --strict 未出现在契约里的字段名也失败(OpenAPI 合主干用这个)',
|
|
72
|
+
' --no-color disable ANSI (or set NO_COLOR=1)',
|
|
73
|
+
'',
|
|
74
|
+
' 两下钉锚(不是同义词):',
|
|
75
|
+
' 抛锚 写盘前 SemanticAnchor MCP bind / verify_usage (吃 constraints.json)',
|
|
76
|
+
' 收紧 合并前 anchor check --contract pin.json --src src --strict',
|
|
77
|
+
' 投影 不是抛锚 anchor constraints --from <pin.json> (给 bind 准备文件)',
|
|
78
|
+
'',
|
|
79
|
+
' 可选: anchor demo # 不碰仓库,看一次红→绿',
|
|
80
|
+
'',
|
|
81
|
+
'Ask one question: do you already have an OpenAPI spec?',
|
|
82
|
+
' Yes → pin the tagged artifact, then check. Do not invent a field list.',
|
|
83
|
+
' No → draft once from code; a human confirms; other repos only check.',
|
|
84
|
+
' Several agents still use those two paths. Same pin; not a third path.',
|
|
85
|
+
'',
|
|
86
|
+
'Do not clone the backend repo. Do not point at a live /v3/api-docs without --hash.',
|
|
87
|
+
'compile / pack / demand-repo stay in @anchorlock/core.',
|
|
88
|
+
'',
|
|
89
|
+
];
|
|
90
|
+
process.stdout.write(lines.join('\n'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatIssue(e) {
|
|
94
|
+
const err = process.stderr;
|
|
95
|
+
if (e.kind === 'drift') {
|
|
96
|
+
return (
|
|
97
|
+
term.bold(term.red(term.quote(e.ident), err), err) +
|
|
98
|
+
' should be ' +
|
|
99
|
+
term.bold(term.green(term.quote(e.expected), err), err) +
|
|
100
|
+
' (' +
|
|
101
|
+
term.dim(e.file, err) +
|
|
102
|
+
')'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (e.kind === 'undeclared') {
|
|
106
|
+
return (
|
|
107
|
+
term.bold(term.red(term.quote(e.ident), err), err) +
|
|
108
|
+
' is not in the contract (' +
|
|
109
|
+
term.dim(e.file, err) +
|
|
110
|
+
')'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return e.message || String(e);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function printGateFail(errors) {
|
|
117
|
+
const n = errors && errors.length ? errors.length : 0;
|
|
118
|
+
const err = process.stderr;
|
|
119
|
+
err.write(
|
|
120
|
+
term.red(term.bold('FAIL', err), err) + ' field-name gate (' + n + ' issues)\n',
|
|
121
|
+
);
|
|
122
|
+
(errors || []).forEach(function (e) {
|
|
123
|
+
err.write(' ' + term.red('×', err) + ' ' + formatIssue(e) + '\n');
|
|
124
|
+
});
|
|
125
|
+
err.write('\n');
|
|
126
|
+
err.write(term.dim('next ', err) + '改代码对齐上面的规范名,不要改 pin / OpenAPI。\n');
|
|
127
|
+
err.write(term.dim(' ', err) + '把本段 FAIL 贴给编码 Agent;改完再跑同一条 check。\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function printGateOk(result) {
|
|
131
|
+
process.stdout.write(
|
|
132
|
+
term.green(term.bold('PASS', process.stdout), process.stdout) +
|
|
133
|
+
' field-name gate (' +
|
|
134
|
+
result.contract.idCount +
|
|
135
|
+
' names · ' +
|
|
136
|
+
result.fileCount +
|
|
137
|
+
' files)\n',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatDraftText(entries) {
|
|
142
|
+
const lines = [
|
|
143
|
+
'# DRAFT — candidate field names from source. Confirm before `anchor check`.',
|
|
144
|
+
'# Do not treat this file as a contract as-is; it is the current (possibly drifted) state.',
|
|
145
|
+
'# 留:会跨进程传的名字(HTTP JSON key、SQL 列名、MQ payload、存储字段)。',
|
|
146
|
+
'# 删:函数名、局部变量、类型名、框架词,以及只在一个文件里出现过的名字。',
|
|
147
|
+
'# 拿不准就删:漏锁只是没保护,错锁会逼别人改本来对的代码。',
|
|
148
|
+
'# Preferred style: snake_case. Delete names you do not want to lock, then run:',
|
|
149
|
+
'# anchor check --contract constraints.json --src src',
|
|
150
|
+
'#',
|
|
151
|
+
];
|
|
152
|
+
entries.forEach(function (e) {
|
|
153
|
+
const others = (e.variants || []).filter(function (v) {
|
|
154
|
+
return v !== e.id;
|
|
155
|
+
});
|
|
156
|
+
let line = e.id;
|
|
157
|
+
if (others.length) line += ' # also in source: ' + others.join(', ');
|
|
158
|
+
lines.push(line);
|
|
159
|
+
});
|
|
160
|
+
return lines.join('\n') + '\n';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function writeJson(obj) {
|
|
164
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readPinFile(contractPath) {
|
|
168
|
+
if (!contractPath) return null;
|
|
169
|
+
const p = path.resolve(contractPath);
|
|
170
|
+
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return null;
|
|
171
|
+
let data;
|
|
172
|
+
try {
|
|
173
|
+
data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (!data || typeof data !== 'object' || !(data.hash || data.sha256)) return null;
|
|
178
|
+
return data;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** writePin 只写 url/hash/version/path;需求关联由本 CLI 追加。 */
|
|
182
|
+
function applyPinChange(outPath, change) {
|
|
183
|
+
if (!outPath || !change) return null;
|
|
184
|
+
const data = JSON.parse(fs.readFileSync(outPath, 'utf8'));
|
|
185
|
+
data.change = change;
|
|
186
|
+
fs.writeFileSync(outPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
187
|
+
return change;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 发版 URL 里通常带着旧 tag,换版时跟着走,省得人手改一长串。 */
|
|
191
|
+
function urlForNextTag(prevUrl, prevVersion, nextTag) {
|
|
192
|
+
if (!prevUrl || !prevVersion || !nextTag || prevVersion === nextTag) return prevUrl;
|
|
193
|
+
if (prevUrl.indexOf(prevVersion) < 0) return prevUrl;
|
|
194
|
+
return prevUrl.split(prevVersion).join(nextTag);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const SRC_EXT = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.vue', '.java', '.kt', '.py', '.go', '.cs']);
|
|
198
|
+
|
|
199
|
+
function gitChangedFiles(root) {
|
|
200
|
+
const opt = { cwd: root, encoding: 'utf8' };
|
|
201
|
+
const diff = spawnSync('git', ['diff', '--name-only', 'HEAD'], opt);
|
|
202
|
+
if (diff.status !== 0) {
|
|
203
|
+
throw new Error('不是 git 仓库,或 git diff 失败。--changed 需要在仓库里跑。');
|
|
204
|
+
}
|
|
205
|
+
const extra = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], opt);
|
|
206
|
+
const names = String(diff.stdout || '')
|
|
207
|
+
.split('\n')
|
|
208
|
+
.concat(String(extra.stdout || '').split('\n'));
|
|
209
|
+
const seen = {};
|
|
210
|
+
const out = [];
|
|
211
|
+
names.forEach(function (n) {
|
|
212
|
+
n = String(n || '').trim();
|
|
213
|
+
if (!n || seen[n]) return;
|
|
214
|
+
if (!SRC_EXT.has(path.extname(n))) return;
|
|
215
|
+
const abs = path.resolve(root, n);
|
|
216
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return;
|
|
217
|
+
seen[n] = true;
|
|
218
|
+
out.push(abs);
|
|
219
|
+
});
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function identRole(text, after) {
|
|
224
|
+
let i = after;
|
|
225
|
+
while (i < text.length && /[ \t]/.test(text[i])) i++;
|
|
226
|
+
if (text[i] === '(') return 'call';
|
|
227
|
+
if (text[i] === '<') {
|
|
228
|
+
const close = text.indexOf('>', i);
|
|
229
|
+
if (close > i && close < i + 80) {
|
|
230
|
+
let j = close + 1;
|
|
231
|
+
while (j < text.length && /[ \t]/.test(text[j])) j++;
|
|
232
|
+
if (text[j] === '(') return 'call';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (text[i] === '=') {
|
|
236
|
+
i += 1;
|
|
237
|
+
while (i < text.length && /[ \t\r\n]/.test(text[i])) i++;
|
|
238
|
+
if (/^(async\b|function\b|\(|=>)/.test(text.slice(i, i + 24))) return 'fn';
|
|
239
|
+
}
|
|
240
|
+
return 'value';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function identUsedAsValue(absPath, ident) {
|
|
244
|
+
let text;
|
|
245
|
+
try {
|
|
246
|
+
text = fs.readFileSync(absPath, 'utf8');
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
const re = new RegExp('\\b' + ident.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
|
|
251
|
+
let m;
|
|
252
|
+
let saw = false;
|
|
253
|
+
while ((m = re.exec(text)) !== null) {
|
|
254
|
+
saw = true;
|
|
255
|
+
if (identRole(text, m.index + ident.length) === 'value') return true;
|
|
256
|
+
}
|
|
257
|
+
return !saw;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function filterUndeclaredCalls(result, root) {
|
|
261
|
+
if (!result || !Array.isArray(result.errors) || !result.errors.length) return result;
|
|
262
|
+
const base = root || process.cwd();
|
|
263
|
+
result.errors = result.errors.filter(function (e) {
|
|
264
|
+
if (e.kind !== 'undeclared' || !e.ident || !e.file) return true;
|
|
265
|
+
return identUsedAsValue(path.resolve(base, e.file), e.ident);
|
|
266
|
+
});
|
|
267
|
+
result.ok = result.errors.length === 0;
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function printScan(label, fileCount, driftOnly) {
|
|
272
|
+
const err = process.stderr;
|
|
273
|
+
if (driftOnly) {
|
|
274
|
+
err.write(
|
|
275
|
+
term.dim(
|
|
276
|
+
' mode drift-only · 只拦名单上的近义词,未声明的字段不报。全量门禁用 --strict。',
|
|
277
|
+
err,
|
|
278
|
+
) + '\n',
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
err.write(term.dim(' mode strict · 名单外的字段名也失败', err) + '\n');
|
|
282
|
+
}
|
|
283
|
+
const n = fileCount || 0;
|
|
284
|
+
err.write(term.dim(' scan ' + label + ' (' + n + (n === 1 ? ' file)' : ' files)'), err) + '\n');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 让人一眼看见自己锁在哪一版说明书上,以及是哪个需求换的。 */
|
|
288
|
+
function printPinLine(result, pinFile) {
|
|
289
|
+
const version = (result.contract && result.contract.version) || (pinFile && pinFile.version);
|
|
290
|
+
const change = pinFile && pinFile.change;
|
|
291
|
+
if (!version && !change) return;
|
|
292
|
+
const parts = [];
|
|
293
|
+
if (version) parts.push(version);
|
|
294
|
+
if (change) parts.push('change ' + change);
|
|
295
|
+
process.stderr.write(term.dim(' pin ' + parts.join(' · '), process.stderr) + '\n');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function runGate(engine, args, src, driftOnly) {
|
|
299
|
+
const result = await engine.checkNames({
|
|
300
|
+
contract: args.contract,
|
|
301
|
+
src: src,
|
|
302
|
+
hash: args.hash,
|
|
303
|
+
requireHash: !!args.requireHash,
|
|
304
|
+
cacheDir: args.cacheDir,
|
|
305
|
+
driftOnly: driftOnly,
|
|
306
|
+
relRoot: process.cwd(),
|
|
307
|
+
});
|
|
308
|
+
return filterUndeclaredCalls(result, process.cwd());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function checkChanged(engine, args, driftOnly) {
|
|
312
|
+
const files = gitChangedFiles(process.cwd());
|
|
313
|
+
const merged = {
|
|
314
|
+
ok: true,
|
|
315
|
+
fileCount: 0,
|
|
316
|
+
errors: [],
|
|
317
|
+
warnings: [],
|
|
318
|
+
contract: { idCount: 0 },
|
|
319
|
+
scanLabel: 'git changes',
|
|
320
|
+
};
|
|
321
|
+
for (let i = 0; i < files.length; i++) {
|
|
322
|
+
const r = await runGate(engine, args, files[i], driftOnly);
|
|
323
|
+
merged.fileCount += r.fileCount || 0;
|
|
324
|
+
merged.errors = merged.errors.concat(r.errors || []);
|
|
325
|
+
merged.warnings = merged.warnings.concat(r.warnings || []);
|
|
326
|
+
if (r.contract) merged.contract = r.contract;
|
|
327
|
+
if (!r.ok) merged.ok = false;
|
|
328
|
+
}
|
|
329
|
+
return merged;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function runCli(argv) {
|
|
333
|
+
const args = parseArgs(argv || process.argv);
|
|
334
|
+
if (args.version) {
|
|
335
|
+
process.stdout.write(PKG.version + '\n');
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
if (args.help || !args._[0]) {
|
|
339
|
+
printHelp();
|
|
340
|
+
return args.help ? 0 : 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const cmd = args._[0];
|
|
344
|
+
const asJson = args.format === 'json';
|
|
345
|
+
if (args.noColor) term.setNoColor(true);
|
|
346
|
+
const engine = loadEngine();
|
|
347
|
+
|
|
348
|
+
if (cmd === 'demo') {
|
|
349
|
+
const demo = require('./demo');
|
|
350
|
+
const out = process.stdout;
|
|
351
|
+
process.stdout.write(
|
|
352
|
+
term.bold('== 1. 碰撞', out) +
|
|
353
|
+
term.dim(' · 名单是 ', out) +
|
|
354
|
+
term.cyan('order_no', out) +
|
|
355
|
+
term.dim(',代码写成 ', out) +
|
|
356
|
+
term.red('orderId', out) +
|
|
357
|
+
term.dim(' / ', out) +
|
|
358
|
+
term.red('order_sn', out) +
|
|
359
|
+
'\n',
|
|
360
|
+
);
|
|
361
|
+
const r = await demo.runDemo(engine.checkNames);
|
|
362
|
+
if (!r.red || r.red.ok) {
|
|
363
|
+
process.stderr.write('demo failed: expected a red gate\n');
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
printGateFail(r.red.errors);
|
|
367
|
+
process.stdout.write(
|
|
368
|
+
'\n' +
|
|
369
|
+
term.bold('== 2. 对齐', out) +
|
|
370
|
+
term.dim(' · 改成名单里的名字', out) +
|
|
371
|
+
'\n',
|
|
372
|
+
);
|
|
373
|
+
if (!r.green || !r.green.ok) {
|
|
374
|
+
process.stderr.write('demo failed: expected a green gate\n');
|
|
375
|
+
printGateFail(r.green && r.green.errors || []);
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
printGateOk(r.green);
|
|
379
|
+
process.stdout.write('\n' + term.green(term.bold('demo ok', out), out) + term.dim(': 碰撞拦住 → 对齐通过', out) + '\n');
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (cmd === 'pin') {
|
|
384
|
+
const result = engine.writePin({
|
|
385
|
+
from: args.from || args._[1],
|
|
386
|
+
url: args.url,
|
|
387
|
+
tag: args.tag,
|
|
388
|
+
out: args.out,
|
|
389
|
+
force: !!args.force,
|
|
390
|
+
dryRun: !!args.dryRun,
|
|
391
|
+
});
|
|
392
|
+
if (result.out) applyPinChange(result.out, args.change);
|
|
393
|
+
if (args.change) result.pin.change = args.change;
|
|
394
|
+
if (asJson) {
|
|
395
|
+
writeJson(result);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
process.stdout.write('Wrote pin.json ' + result.pin.version + ' ' + result.hash + '\n');
|
|
399
|
+
process.stdout.write(' from ' + result.from + '\n');
|
|
400
|
+
process.stdout.write(' url ' + result.pin.url + '\n');
|
|
401
|
+
if (args.change) process.stdout.write(' change ' + args.change + '\n');
|
|
402
|
+
process.stdout.write(' out ' + (result.out || '(dry-run, not written)') + '\n');
|
|
403
|
+
process.stdout.write(
|
|
404
|
+
'\nNext:\n anchor check --contract ' +
|
|
405
|
+
(result.out || 'contracts/order.pin.json') +
|
|
406
|
+
' --src src --strict --require-hash\n' +
|
|
407
|
+
' # 后端下次发版换 tag:anchor repin --from <新 openapi.json> --tag <vX.Y.Z>\n',
|
|
408
|
+
);
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (cmd === 'repin') {
|
|
413
|
+
const target = path.resolve(
|
|
414
|
+
args.out || args.contract || args._[1] || 'contracts/order.pin.json',
|
|
415
|
+
);
|
|
416
|
+
const prev = readPinFile(target);
|
|
417
|
+
if (!prev) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
'没有现成的 pin 可换:' + target + '(第一次请用 anchor pin --from … --url … --tag …)',
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (!args.from) {
|
|
423
|
+
throw new Error('repin 需要 --from <后端这次发版导出的 openapi.json>');
|
|
424
|
+
}
|
|
425
|
+
const url = args.url || urlForNextTag(prev.url, prev.version, args.tag);
|
|
426
|
+
const result = engine.writePin({
|
|
427
|
+
from: args.from,
|
|
428
|
+
url: url,
|
|
429
|
+
tag: args.tag,
|
|
430
|
+
out: target,
|
|
431
|
+
force: true,
|
|
432
|
+
dryRun: !!args.dryRun,
|
|
433
|
+
});
|
|
434
|
+
const change = args.change || prev.change || null;
|
|
435
|
+
if (result.out) applyPinChange(result.out, change);
|
|
436
|
+
if (change) result.pin.change = change;
|
|
437
|
+
const sameHash = prev.hash === result.hash;
|
|
438
|
+
if (asJson) {
|
|
439
|
+
writeJson({ out: result.out, pin: result.pin, previous: prev, unchanged: sameHash });
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
process.stdout.write('Repinned ' + path.relative(process.cwd(), target) + '\n');
|
|
443
|
+
process.stdout.write(' version ' + (prev.version || '(none)') + ' → ' + result.pin.version + '\n');
|
|
444
|
+
process.stdout.write(' hash ' + (prev.hash || '(none)') + '\n');
|
|
445
|
+
process.stdout.write(' ' + result.hash + '\n');
|
|
446
|
+
process.stdout.write(' url ' + result.pin.url + '\n');
|
|
447
|
+
if (change) process.stdout.write(' change ' + change + '\n');
|
|
448
|
+
if (sameHash) {
|
|
449
|
+
process.stderr.write(
|
|
450
|
+
term.dim('warn ') + '制品和上一版一模一样(hash 没变)。后端可能还没重新导出。\n',
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
process.stdout.write(
|
|
454
|
+
'\nNext:\n git add ' +
|
|
455
|
+
path.relative(process.cwd(), target) +
|
|
456
|
+
'\n anchor check --contract ' +
|
|
457
|
+
path.relative(process.cwd(), target) +
|
|
458
|
+
' --src src --strict --require-hash\n' +
|
|
459
|
+
' # 红了改代码对齐新说明书,不要回退 pin。\n',
|
|
460
|
+
);
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (cmd === 'constraints') {
|
|
465
|
+
if (args.init && args.from) {
|
|
466
|
+
throw new Error('do not pass --init with --from. Have OpenAPI? use --from. Have code? use --from-code.');
|
|
467
|
+
}
|
|
468
|
+
if (args.fromCode) {
|
|
469
|
+
const result = engine.draftConstraintsFromCode({
|
|
470
|
+
src: args.src || args._[1],
|
|
471
|
+
out: args.out,
|
|
472
|
+
force: !!args.force,
|
|
473
|
+
dryRun: !!args.dryRun,
|
|
474
|
+
source: args.source,
|
|
475
|
+
});
|
|
476
|
+
if (asJson) {
|
|
477
|
+
writeJson({
|
|
478
|
+
out: result.out,
|
|
479
|
+
unitCount: result.unitCount,
|
|
480
|
+
fromCode: true,
|
|
481
|
+
units: result.doc && result.doc.units,
|
|
482
|
+
});
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
process.stdout.write(
|
|
486
|
+
'Drafted constraints.json from source (' +
|
|
487
|
+
result.unitCount +
|
|
488
|
+
' candidates · ' +
|
|
489
|
+
(result.fileCount || 0) +
|
|
490
|
+
' files). Confirm it before check / cast.\n',
|
|
491
|
+
);
|
|
492
|
+
process.stdout.write(' out ' + (result.out || '(dry-run, not written)') + '\n');
|
|
493
|
+
process.stdout.write(
|
|
494
|
+
'\nThis is a DRAFT. A human must confirm ids before check.\n anchor check --contract ' +
|
|
495
|
+
(result.out || 'constraints.json') +
|
|
496
|
+
' --src ' +
|
|
497
|
+
(args.src || 'src') +
|
|
498
|
+
'\n',
|
|
499
|
+
);
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
const wantInit = !!args.init || !args.from;
|
|
503
|
+
const result = wantInit
|
|
504
|
+
? engine.writeConstraintsTemplate({
|
|
505
|
+
out: args.out,
|
|
506
|
+
force: !!args.force,
|
|
507
|
+
dryRun: !!args.dryRun,
|
|
508
|
+
source: args.source,
|
|
509
|
+
})
|
|
510
|
+
: await engine.writeConstraintsFromOpenApi({
|
|
511
|
+
from: args.from,
|
|
512
|
+
out: args.out,
|
|
513
|
+
force: !!args.force,
|
|
514
|
+
dryRun: !!args.dryRun,
|
|
515
|
+
hash: args.hash,
|
|
516
|
+
requireHash: !!args.requireHash,
|
|
517
|
+
cacheDir: args.cacheDir,
|
|
518
|
+
source: args.source,
|
|
519
|
+
});
|
|
520
|
+
if (asJson) {
|
|
521
|
+
writeJson({
|
|
522
|
+
out: result.out,
|
|
523
|
+
unitCount: result.unitCount,
|
|
524
|
+
unresolvedCount: result.unresolvedCount,
|
|
525
|
+
template: !!result.template,
|
|
526
|
+
units: result.doc && result.doc.units,
|
|
527
|
+
unresolved: result.doc && result.doc.unresolved,
|
|
528
|
+
});
|
|
529
|
+
return 0;
|
|
530
|
+
}
|
|
531
|
+
if (result.template) {
|
|
532
|
+
process.stdout.write(
|
|
533
|
+
'Wrote constraints.json template (' +
|
|
534
|
+
result.unitCount +
|
|
535
|
+
' example unit). Edit it, then check / cast against this file.\n',
|
|
536
|
+
);
|
|
537
|
+
} else {
|
|
538
|
+
process.stdout.write(
|
|
539
|
+
'Projected OpenAPI → constraints.json (' +
|
|
540
|
+
result.unitCount +
|
|
541
|
+
' units' +
|
|
542
|
+
(result.unresolvedCount ? ' · ' + result.unresolvedCount + ' unresolved' : '') +
|
|
543
|
+
')\n',
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
process.stdout.write(' out ' + (result.out || '(dry-run, not written)') + '\n');
|
|
547
|
+
process.stdout.write(
|
|
548
|
+
'\nNext:\n anchor check --contract ' +
|
|
549
|
+
(result.out || 'constraints.json') +
|
|
550
|
+
' --src src\n',
|
|
551
|
+
);
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (cmd === 'init') {
|
|
556
|
+
if (!args.fromCode && args._[1] && !args.src) {
|
|
557
|
+
args.fromCode = true;
|
|
558
|
+
args.src = args._[1];
|
|
559
|
+
}
|
|
560
|
+
if (!args.fromCode) {
|
|
561
|
+
const agent = require('./install-agent');
|
|
562
|
+
const installed = agent.installAgentFiles(process.cwd(), { dryRun: !!args.dryRun });
|
|
563
|
+
if (asJson) {
|
|
564
|
+
writeJson({
|
|
565
|
+
skill: installed.skill,
|
|
566
|
+
agents: installed.agents,
|
|
567
|
+
wroteConstraints: false,
|
|
568
|
+
});
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
process.stdout.write('Installed AnchorLock agent flow\n');
|
|
572
|
+
installed.skill.forEach(function (p) {
|
|
573
|
+
process.stdout.write(term.dim(' skill ') + path.relative(process.cwd(), p) + '\n');
|
|
574
|
+
});
|
|
575
|
+
process.stdout.write(term.dim(' agents ') + path.relative(process.cwd(), installed.agents) + '\n');
|
|
576
|
+
process.stdout.write(
|
|
577
|
+
'\n不写字段名单。有 OpenAPI 就 pin 再 check;没有就一处 --from-code,人确认后再 check。\n' +
|
|
578
|
+
'不要从需求里编 constraints.json。\n' +
|
|
579
|
+
term.cyan(' anchor pin --from dist/openapi.json --url <tagged-url> --tag vX.Y.Z\n') +
|
|
580
|
+
term.cyan(' anchor check --contract contracts/order.pin.json --src src --strict --require-hash\n'),
|
|
581
|
+
);
|
|
582
|
+
return 0;
|
|
583
|
+
}
|
|
584
|
+
if (!args.src) args.src = args._[1];
|
|
585
|
+
const result = engine.draftConstraintsFromCode({
|
|
586
|
+
src: args.src,
|
|
587
|
+
out: args.out,
|
|
588
|
+
force: !!args.force,
|
|
589
|
+
dryRun: !!args.dryRun,
|
|
590
|
+
source: args.source,
|
|
591
|
+
});
|
|
592
|
+
if (asJson) {
|
|
593
|
+
writeJson({
|
|
594
|
+
out: result.out,
|
|
595
|
+
unitCount: result.unitCount,
|
|
596
|
+
fromCode: true,
|
|
597
|
+
src: result.src,
|
|
598
|
+
units: result.doc && result.doc.units,
|
|
599
|
+
});
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
process.stdout.write(
|
|
603
|
+
'Drafted constraints.json from source (' +
|
|
604
|
+
result.unitCount +
|
|
605
|
+
' candidates · ' +
|
|
606
|
+
(result.fileCount || 0) +
|
|
607
|
+
' files). Confirm before check / cast.\n',
|
|
608
|
+
);
|
|
609
|
+
process.stdout.write(' src ' + result.src + '\n');
|
|
610
|
+
process.stdout.write(' out ' + (result.out || '(dry-run, not written)') + '\n');
|
|
611
|
+
process.stdout.write(
|
|
612
|
+
'\nThis is a DRAFT. A human must confirm ids before check.\n anchor check --contract ' +
|
|
613
|
+
(result.out || 'constraints.json') +
|
|
614
|
+
' --src ' +
|
|
615
|
+
(args.src || 'src') +
|
|
616
|
+
'\n',
|
|
617
|
+
);
|
|
618
|
+
return 0;
|
|
619
|
+
} else if (cmd !== 'check' && cmd !== 'constraints') {
|
|
620
|
+
throw new Error(
|
|
621
|
+
'unknown command: ' +
|
|
622
|
+
cmd +
|
|
623
|
+
' (this CLI has check / pin / repin / constraints / init / demo). Full workshop: anchorlock --help (@anchorlock/core)',
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (args.draft || args.fromCode) {
|
|
628
|
+
if (args.contract) {
|
|
629
|
+
throw new Error('do not pass --contract with --draft / --from-code. Draft first, confirm, then check.');
|
|
630
|
+
}
|
|
631
|
+
const result = engine.draftConstraintsFromCode({
|
|
632
|
+
src: args.src,
|
|
633
|
+
out: args.out,
|
|
634
|
+
force: !!args.force,
|
|
635
|
+
dryRun: !!args.dryRun,
|
|
636
|
+
});
|
|
637
|
+
if (asJson) {
|
|
638
|
+
writeJson({
|
|
639
|
+
out: result.out,
|
|
640
|
+
unitCount: result.unitCount,
|
|
641
|
+
fromCode: true,
|
|
642
|
+
src: result.src,
|
|
643
|
+
units: result.doc && result.doc.units,
|
|
644
|
+
});
|
|
645
|
+
return 0;
|
|
646
|
+
}
|
|
647
|
+
process.stdout.write(
|
|
648
|
+
'Drafted constraints.json from source (' +
|
|
649
|
+
result.unitCount +
|
|
650
|
+
' candidates · ' +
|
|
651
|
+
(result.fileCount || 0) +
|
|
652
|
+
' files). Confirm before check / cast.\n',
|
|
653
|
+
);
|
|
654
|
+
process.stdout.write(' src ' + result.src + '\n');
|
|
655
|
+
process.stdout.write(' out ' + (result.out || '(dry-run, not written)') + '\n');
|
|
656
|
+
(result.doc.units || [])
|
|
657
|
+
.filter(function (u) {
|
|
658
|
+
return u.aliases && u.aliases.length;
|
|
659
|
+
})
|
|
660
|
+
.slice(0, 12)
|
|
661
|
+
.forEach(function (u) {
|
|
662
|
+
process.stdout.write(' ~ ' + u.id + ' ← ' + u.aliases.join(' / ') + '\n');
|
|
663
|
+
});
|
|
664
|
+
process.stdout.write(
|
|
665
|
+
'\nThis is a DRAFT. A human must confirm ids before check.\n anchor check --contract ' +
|
|
666
|
+
(result.out || 'constraints.json') +
|
|
667
|
+
' --src ' +
|
|
668
|
+
(args.src || 'src') +
|
|
669
|
+
'\n',
|
|
670
|
+
);
|
|
671
|
+
return 0;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (args.changed && args.src) {
|
|
675
|
+
throw new Error('不要同时传 --src 和 --changed。人用 --changed 或什么都不传;--src 留给 CI / Agent。');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const driftOnly = !args.strict;
|
|
679
|
+
let result;
|
|
680
|
+
let scanLabel;
|
|
681
|
+
if (args.changed) {
|
|
682
|
+
result = await checkChanged(engine, args, driftOnly);
|
|
683
|
+
scanLabel = result.scanLabel;
|
|
684
|
+
} else {
|
|
685
|
+
const src = args.src || process.cwd();
|
|
686
|
+
scanLabel = path.relative(process.cwd(), src) || '.';
|
|
687
|
+
result = await runGate(engine, args, src, driftOnly);
|
|
688
|
+
}
|
|
689
|
+
if (asJson) {
|
|
690
|
+
result.scan = scanLabel;
|
|
691
|
+
writeJson(result);
|
|
692
|
+
return result.ok ? 0 : 1;
|
|
693
|
+
}
|
|
694
|
+
printScan(scanLabel, result.fileCount, driftOnly);
|
|
695
|
+
printPinLine(result, readPinFile(args.contract));
|
|
696
|
+
if (result.ok) {
|
|
697
|
+
printGateOk(result);
|
|
698
|
+
if (result.contract && result.contract.sha256) {
|
|
699
|
+
process.stdout.write(
|
|
700
|
+
term.dim(' sha256 ') +
|
|
701
|
+
result.contract.sha256 +
|
|
702
|
+
(result.contract.pinned ? ' pinned' : ' unpinned') +
|
|
703
|
+
'\n',
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
(result.warnings || []).forEach(function (w) {
|
|
707
|
+
if (w.kind === 'generic') return;
|
|
708
|
+
process.stderr.write(term.dim('warn ') + (w.message || String(w)) + '\n');
|
|
709
|
+
});
|
|
710
|
+
return 0;
|
|
711
|
+
}
|
|
712
|
+
printGateFail(result.errors);
|
|
713
|
+
return 1;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
module.exports = {
|
|
717
|
+
runCli,
|
|
718
|
+
parseArgs,
|
|
719
|
+
formatIssue,
|
|
720
|
+
formatDraftText,
|
|
721
|
+
identRole,
|
|
722
|
+
filterUndeclaredCalls,
|
|
723
|
+
urlForNextTag,
|
|
724
|
+
};
|