@agile-team/wl-skills-gate 0.13.0 → 0.13.2

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.
@@ -0,0 +1,125 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { notificationText } from './notification-style.js';
3
+ import { formatNotificationTime } from './notification-time.js';
4
+
5
+ // Operations readiness is independent from quality/admission. Missing project
6
+ // configuration must still produce an actionable CI notice, without requiring
7
+ // that project's governance config or any source-controlled notification code.
8
+ const clean = (value, max = 180) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ')
9
+ .replace(/https?:\/\/[^\s<>)]+/g, raw => { try { const u = new URL(raw); return `${u.origin}${u.pathname}`; } catch { return '[地址已隐藏]'; } })
10
+ .replace(/\b(token|password|secret|authorization)\s*[:=]\s*\S+/gi, '$1=[已隐藏]').slice(0, max);
11
+ const md = value => clean(value).replace(/[\\`*_~\[\]<>]/g, '\\$&');
12
+ const safeId = id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id) && id !== 'all';
13
+ const verifiedPerson = p => !!(p?.name && ['open_id', 'user_id'].includes(p.feishu?.idType) && safeId(p.feishu.id)
14
+ && (p.feishu.idType !== 'open_id' || p.feishu.id.startsWith('ou_'))
15
+ && (p.feishu.verified === true || p.feishu.identityVerified === true));
16
+ const category = id => /gate|package|runtime|node|python|java|tool|engine/i.test(id) ? 'Gate/执行环境'
17
+ : /owner|people|person|actor|mention|manager/i.test(id) ? '责任人/提醒'
18
+ : /credential|token|secret|auth|permission/i.test(id) ? '凭据/访问权限'
19
+ : /review|trust|sign/i.test(id) ? 'Review/准入'
20
+ : /hook|trigger|branch|event/i.test(id) ? '触发/分支接线'
21
+ : /registry|binding|config|policy|fleet|source|automation|project|check/i.test(id) ? '项目/检查配置' : '其他接入缺项';
22
+ const httpsUrl = value => { try { const u = new URL(value); return u.protocol === 'https:' && u.hostname && !u.username && !u.password && !u.hash ? u : null; } catch { return null; } };
23
+
24
+ function recipients(project, config) {
25
+ let owner = config.owners?.[project.key] ?? config.owners?.[project.project] ?? {};
26
+ if (typeof owner === 'string') owner = { technicalManager: owner };
27
+ const manager = project.technicalManager || owner.technicalManager;
28
+ const executor = project.executor || owner.executor;
29
+ const same = !!manager && !!executor && (manager === executor || (verifiedPerson(config.people?.[manager]) && verifiedPerson(config.people?.[executor]) && config.people[manager].feishu.id === config.people[executor].feishu.id));
30
+ const observer = same ? project.managerSubmissionNotify || owner.managerSubmissionNotify : null;
31
+ const actors = [...new Set([executor, manager, observer, ...(Array.isArray(owner.recipients) ? owner.recipients : [])].filter(v => typeof v === 'string' && v))];
32
+ return { manager, executor, observer, actors };
33
+ }
34
+
35
+ export function opsNotification(report, { people = {}, owners = {}, fallbackRecipients = [] } = {}) {
36
+ if (report?.kind !== 'jenkins-ops-report' || !Array.isArray(report.projects)
37
+ || report.projects.some(p => !p || !['attention', 'ready', 'unverified'].includes(p.status) || !Array.isArray(p.gaps))
38
+ || (report.globalGaps !== undefined && !Array.isArray(report.globalGaps))) throw new Error('需要真实运维接入检查报告');
39
+ const config = { people, owners }, projects = report.projects;
40
+ const globalGaps = report.globalGaps || [], gaps = [...globalGaps, ...projects.flatMap(p => p.gaps)];
41
+ const pending = projects.filter(p => p.status !== 'ready');
42
+ const unresolved = globalGaps.length > 0 || pending.length > 0 || gaps.length > 0 || report.status === 'attention';
43
+ const recovered = report.recovered === true && !unresolved;
44
+ const mentioned = new Set(), used = new Set(), missing = new Set();
45
+ const person = actor => {
46
+ if (!actor) { missing.add('责任人'); return '责任人待登记'; }
47
+ const p = people[actor];
48
+ if (verifiedPerson(p)) {
49
+ mentioned.add(p.feishu.id);
50
+ if (!used.has(p.feishu.id)) { used.add(p.feishu.id); return `<at id="${p.feishu.id}"></at>`; }
51
+ } else missing.add(actor);
52
+ return md(p?.name || actor);
53
+ };
54
+ const summaryCounts = { attention: projects.filter(p => p.status === 'attention').length, ready: projects.filter(p => p.status === 'ready').length, unverified: projects.filter(p => p.status === 'unverified').length };
55
+ const title = recovered ? '接入缺项已消除|转入九维检查' : unresolved ? '流水线接入检查|需处理' : '流水线接入检查|前置就绪';
56
+ const verdict = recovered ? '接入缺项已消除|转入现有九维检查与Review准入' : unresolved ? '暂停质量放行|先补齐接入缺项,修复后自动复检' : '接入前置就绪|进入现有九维检查与Review准入';
57
+ const elements = [notificationText(verdict, recovered ? 'green' : unresolved ? 'red' : 'blue'),
58
+ { tag: 'markdown', text_size: 'normal', content: `**检查时间**:${formatNotificationTime(report.checkedAt)}\n**项目**:${projects.length} | **待补齐**:${summaryCounts.attention} | **待验证**:${summaryCounts.unverified} | **前置就绪**:${summaryCounts.ready}` }];
59
+ if (gaps.length) {
60
+ const groups = new Map();
61
+ for (const gap of gaps) { const key = category(String(gap.id || '')); groups.set(key, (groups.get(key) || 0) + 1); }
62
+ elements.push(notificationText(`接入缺项 ${gaps.length} 项:${[...groups].map(([name, count]) => `${name} ${count}`).join(' · ')}`, 'orange'));
63
+ }
64
+ if (globalGaps.length) elements.push({ tag: 'markdown', text_size: 'normal', content: `**公共配置**\n${globalGaps.slice(0, 2).map(g => `• ${md(g.message || g.id)}\n 处理:${md(g.action || '按本次归档补齐配置,再重跑检查')}`).join('\n')}${globalGaps.length > 2 ? `\n另有 ${globalGaps.length - 2} 项,详见本次记录。` : ''}` });
65
+ const ordered = [...projects].sort((a, b) => (a.status === 'ready') - (b.status === 'ready'));
66
+ const visible = ordered.slice(0, 3);
67
+ for (const p of visible) {
68
+ const role = recipients(p, config);
69
+ const who = [role.executor ? `执行人 ${person(role.executor)}` : '', `技术经理 ${person(role.manager)}`, role.observer ? `同步 ${person(role.observer)}` : ''].filter(Boolean).join(' | ');
70
+ const label = p.status === 'ready' ? '前置就绪' : p.status === 'unverified' ? '待验证' : '待补齐';
71
+ const body = p.gaps.length ? p.gaps.slice(0, 2).map(g => `• ${md(g.message || g.id)}\n 处理:${md(g.action || '按本次归档补齐配置,再重跑检查')}`).join('\n') + (p.gaps.length > 2 ? `\n另有 ${p.gaps.length - 2} 项,详见本次记录。` : '') : '接入前置已核对;九维质量、Review授权和构建准入仍须按实际检查执行。';
72
+ elements.push({ tag: 'markdown', text_size: 'normal', content: `**${md(p.project || p.key)} · ${md(p.branch || '分支待识别')} · ${label}**\n${who}\n${body}` });
73
+ }
74
+ // Resolve every affected project's recipients even when the visual preview is
75
+ // capped. Hidden projects must not silently lose their required reminder.
76
+ const reminderActors = [...new Set(projects.flatMap(p => recipients(p, config).actors).concat(fallbackRecipients.filter(v => typeof v === 'string' && v)))];
77
+ const extraReminders = reminderActors.filter(actor => {
78
+ const p = people[actor];
79
+ return !verifiedPerson(p) || !used.has(p.feishu.id);
80
+ }).map(person);
81
+ if (projects.length > visible.length) elements.push(notificationText(`仅展示 ${visible.length}/${projects.length} 个项目;其余 ${projects.length - visible.length} 个项目及完整缺项见本次记录。`, 'grey'));
82
+ const run = httpsUrl(report.runUrl), origin = httpsUrl(report.jenkinsOrigin);
83
+ if (run && origin && run.origin === origin.origin && !run.search) elements.push({ tag: 'button', type: 'primary', text: { tag: 'plain_text', content: '查看本次接入检查与完整缺项' }, behaviors: [{ type: 'open_url', default_url: run.href }] });
84
+ else elements.push(notificationText('详情见本次 Jenkins 归档;尚未提供可验证的 HTTPS 记录地址。', 'grey', 'notation'));
85
+ if (!reminderActors.length && !visible.some(p => recipients(p, config).manager)) missing.add('责任人');
86
+ if (extraReminders.length) elements.push({ tag: 'markdown', text_size: 'normal', content: `**请关注并完成闭环**:${extraReminders.join('、')}\n请补齐接入缺项,完成复检和九维检查,跟进修复与回归结果。` });
87
+ else elements.push(notificationText('请责任人补齐缺项并复检,跟进九维检查、修复与回归闭环。', 'blue'));
88
+ if (missing.size) elements.push(notificationText('责任人或真实 @ 尚未配置;姓名/工号文字不代表已触达,请工程维护者补齐已验证的人员映射。', 'orange', 'notation'));
89
+ elements.push(notificationText('未接入不等于扫描通过。前置就绪仅表示可继续检查;本通知不授予构建、部署或Review签发权限。', 'grey', 'notation'));
90
+ const payload = { msg_type: 'interactive', card: { schema: '2.0', header: { template: recovered ? 'green' : unresolved ? 'orange' : 'blue', padding: '10px 16px 10px 16px', title: { tag: 'plain_text', content: title } }, body: { padding: '10px 16px 12px 16px', vertical_spacing: '6px', elements } } };
91
+ if (Buffer.byteLength(JSON.stringify(payload)) > 20000) throw new Error('运维通知超过飞书卡片预算,请缩小盘点批次');
92
+ return { payload, mentions: [...mentioned], mentionMappingComplete: missing.size === 0 };
93
+ }
94
+
95
+ export async function sendOpsNotification(report, config = {}, { env = process.env, fetchImpl = fetch, now = Date.now } = {}) {
96
+ const notification = opsNotification(report, config);
97
+ const receipt = (status, reason) => ({ status, reason, mentions: notification.mentions, mentionMappingComplete: notification.mentionMappingComplete, qualityPassed: false, buildAuthorized: false });
98
+ const webhook = env.WL_FEISHU_CI_WEBHOOK, secret = env.WL_FEISHU_CI_SECRET;
99
+ const url = httpsUrl(webhook);
100
+ if (!url || url.hostname !== 'open.feishu.cn' || url.port || url.search || url.hash || !/^\/open-apis\/bot\/v2\/hook\/[A-Za-z0-9-]+$/.test(url.pathname) || typeof secret !== 'string' || !secret) return receipt('not-sent', '缺少批准的流水线机器人地址或签名凭据;请配置CI机器人,不能使用项目机器人替代');
101
+ const instant = typeof now === 'function' ? now() : now;
102
+ if (!Number.isFinite(instant)) throw new Error('通知时间无效');
103
+ const timestamp = String(Math.floor(instant / 1000));
104
+ const body = { ...notification.payload, timestamp, sign: createHmac('sha256', `${timestamp}\n${secret}`).update('').digest('base64') };
105
+ let response;
106
+ try { response = await fetchImpl(url.href, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(10000) }); }
107
+ catch { return receipt('unknown', '网络或超时,送达状态未知;先核对群记录,不自动重发'); }
108
+ if (response.status !== 200) return receipt('rejected', '飞书返回HTTP错误;本次未确认送达');
109
+ try {
110
+ const length = Number(response.headers?.get?.('content-length'));
111
+ if (Number.isFinite(length) && length > 65536) return receipt('unknown', '响应过大,无法确认送达;先核对群记录');
112
+ // Read a bounded stream when available. Test/provider adapters may only
113
+ // implement text(); their result is still checked before parsing.
114
+ let raw = '';
115
+ if (response.body?.[Symbol.asyncIterator]) {
116
+ const chunks = []; let bytes = 0;
117
+ for await (const chunk of response.body) { bytes += Buffer.byteLength(chunk); if (bytes > 65536) return receipt('unknown', '响应过大,无法确认送达;先核对群记录'); chunks.push(Buffer.from(chunk)); }
118
+ raw = Buffer.concat(chunks).toString('utf8');
119
+ } else if (typeof response.text === 'function') raw = await response.text();
120
+ else if (typeof response.json === 'function') raw = JSON.stringify(await response.json());
121
+ if (Buffer.byteLength(raw) > 65536) return receipt('unknown', '响应过大,无法确认送达;先核对群记录');
122
+ const result = JSON.parse(raw), codes = ['code', 'StatusCode'].filter(k => Object.hasOwn(result || {}, k)).map(k => result[k]);
123
+ return codes.length && codes.every(code => Number.isInteger(code) && code === 0) ? receipt('sent', '飞书已确认接收') : receipt('rejected', '飞书未确认接收;检查CI机器人安全设置');
124
+ } catch { return receipt('unknown', '飞书响应无法校验,送达状态未知;先核对群记录,不自动重发'); }
125
+ }
@@ -0,0 +1,129 @@
1
+ import path from 'node:path';
2
+ import {existsSync,readFileSync,readdirSync,lstatSync} from 'node:fs';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {loadJenkinsCluster,MAINTENANCE_JOBS} from './jenkins-cluster.js';
5
+ import {controlledPath,readControlledJson,writeExclusiveJson,lockedJson} from './control-store.js';
6
+ import {GATE_ROOT,GATE_VERSION,gateRuntimeHash,validateJenkinsRegistry,digest,assertBoundCheckout} from './jenkins-integration.js';
7
+ import {doctor} from './doctor.js';
8
+ import {safePath,actualSha,git} from './source-state.js';
9
+ import {stableStringify} from './util.js';
10
+ import {inspectJenkinsReadiness} from './jenkins-readiness.js';
11
+ import {entryPointHash,nativePipelineEntry} from './jenkins-entry-contract.js';
12
+ import {jobContract} from './jenkins-job-config.js';
13
+ import {fleetClient,jobPath} from './jenkins-fleet-client.js';
14
+ import {claimOpsCheck,acknowledgeOpsCheck} from './jenkins-ops-checks.js';
15
+ export {claimOpsCheck,acknowledgeOpsCheck} from './jenkins-ops-checks.js';
16
+ const assert=(ok,message)=>{if(!ok)throw Error(message);};
17
+ const gap=(id,message,action)=>({id,message,action});
18
+ const branch=v=>typeof v==='string'&&/^[A-Za-z0-9][A-Za-z0-9._/-]{0,150}$/.test(v)&&!v.includes('..')&&!v.endsWith('/')&&!v.includes('//');
19
+ const normalizeBranch=v=>String(v||'').replace(/^(?:refs\/heads\/|origin\/|\*\/)/,'');
20
+ const hash=v=>digest(stableStringify(v));
21
+ export function defaultOpsConfiguration(){return {schemaVersion:1,kind:'wl-quality-jenkins-ops',branches:['dev','sit','uat','pre','prd','main','test'],inventory:{roots:[''],maxJobs:1000,maxDepth:8},jobMappings:{},people:{},owners:{},fallbackRecipients:[]};}
22
+ export function opsContext(clusterFile){
23
+ const c=loadJenkinsCluster(clusterFile);
24
+ assert(c.nativeEntry.runtimeHash===gateRuntimeHash()&&digest(readFileSync(controlledPath(c.nativeEntry.loaderFile)))===c.nativeEntry.loaderHash,'公共入口或固定Gate漂移,先重新准备节点');
25
+ const file=path.join(c.configDir,'jenkins-ops.json'),o=existsSync(file)?readControlledJson(file):defaultOpsConfiguration();
26
+ assert(o.schemaVersion===1&&o.kind==='wl-quality-jenkins-ops'&&Object.keys(o).every(k=>['schemaVersion','kind','branches','inventory','jobMappings','people','owners','fallbackRecipients'].includes(k)),'运维巡检配置无效');
27
+ assert(Array.isArray(o.branches)&&o.branches.length>0&&o.branches.every(branch)&&new Set(o.branches).size===o.branches.length,'巡检分支须明确且无重复');
28
+ for(const k of ['jobMappings','people','owners'])assert(o[k]&&typeof o[k]==='object'&&!Array.isArray(o[k]),'运维巡检配置缺少'+k);
29
+ const errors=[],bindings=[];
30
+ for(const f of c.registryFiles){try{const r=validateJenkinsRegistry(readControlledJson(f));assert(r.jenkinsOrigin===c.jenkinsOrigin&&r.gateRoot===GATE_ROOT,'登记不是当前集群固定Gate');assert(entryPointHash(r.nativeEntry)===entryPointHash(c.nativeEntry),'登记公共入口已漂移');for(const b of r.bindings){assert(b.runtimeHash===gateRuntimeHash(),'登记Gate版本或字节已漂移');bindings.push({registryFile:f,binding:b,agentLabel:r.agentLabel});}}catch(e){errors.push(gap('REGISTRY','项目登记未就绪:'+path.basename(f),'按实际项目生成批准登记;当前不能启动构建'));}}
31
+ return {c,o,bindings,registryErrors:errors};
32
+ }
33
+ function repository(value){
34
+ if(typeof value!=='string'||value.length>1000||/[\s\u0000-\u001f]/.test(value))return false;
35
+ if(/^git@[A-Za-z0-9.-]+:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/.test(value))return true;
36
+ try{const u=new URL(value);return ['https:','ssh:'].includes(u.protocol)&&!!u.hostname&&!u.password&&(!u.username||u.protocol==='ssh:'&&u.username==='git')&&!u.search&&!u.hash&&/^\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/.test(u.pathname);}catch{return false;}
37
+ }
38
+ function targetKey(v){return hash({job:v.job,repository:v.repository||null,branch:v.branch||null}).slice(0,24);}
39
+ function checkInventory(i,c){assert(i?.kind==='jenkins-ops-inventory'&&i.clusterHash===c.configHash&&i.runtimeHash===gateRuntimeHash()&&Array.isArray(i.targets)&&typeof i.runId==='string','盘点不是当前集群/固定Gate生成的本轮原件');}
40
+ export async function inventoryOps({clusterFile,env=process.env,fetchImpl=fetch}){
41
+ const {c,o,bindings,registryErrors}=opsContext(clusterFile);
42
+ const {discoverJenkinsJobs}=await import('./jenkins-inventory.js');
43
+ const discovered=await discoverJenkinsJobs({jenkinsOrigin:c.jenkinsOrigin,inventory:o.inventory,env,fetchImpl});
44
+ const client=fleetClient({origin:c.jenkinsOrigin,env,fetchImpl}),targets=[];
45
+ for(const row of discovered.jobs){
46
+ if(Object.values(MAINTENANCE_JOBS).includes(row.job))continue;
47
+ const matches=bindings.filter(b=>Object.values(b.binding.jobs).includes(row.job));
48
+ const gaps=[],bound=matches.length===1?matches[0]:null,mapping=o.jobMappings[row.job];
49
+ if(matches.length>1)gaps.push(gap('DUPLICATE-REGISTRY','Job对应多份登记','清理重复登记后复查'));
50
+ const candidates=row.scmCandidates||[];
51
+ const scm=bound?{repository:bound.binding.repository,credentialId:bound.binding.scmCredentialId,branches:[bound.binding.branch]}:mapping?{repository:mapping.repository,credentialId:mapping.credentialId,branches:[mapping.branch]}:candidates.length===1?candidates[0]:null;
52
+ const branches=scm?.branches?.map(normalizeBranch)||[];
53
+ if(branches.length===1&&branch(branches[0])&&!o.branches.includes(branches[0]))continue;
54
+ const t={job:row.job,jobs:[row.job],project:bound?.binding.project||mapping?.project||scm?.repository?.split(/[/:]/).at(-1)?.replace(/\.git$/,'')||row.job,repository:scm?.repository,credentialId:scm?.credentialId,branch:branches.length===1?branches[0]:null,gaps,configHash:row.configHash,managed:!!bound,bindingId:bound?.binding.id,registryFile:bound?.registryFile};
55
+ if(!scm||!repository(t.repository)||!branch(t.branch)||!t.credentialId||!(/^[A-Za-z0-9_. -]{1,150}$/.test(t.credentialId))){gaps.push(gap('SCM-LOCATION','仓库、固定分支或SCM凭据不能唯一定位','在jenkins-ops.json的jobMappings一次确认该Job的仓库、分支、凭据ID;不按Job/视图名称猜测'));}
56
+ if(!bound)gaps.push(gap('UNCONTROLLED-JOB','原流水线尚未接入Gate,当前不能证明它会拦截构建','完成项目接入与批准接线;巡检不会覆盖自定义部署/回滚脚本'));
57
+ if(!bound&&!mapping&&row.discoveryGaps?.some(v=>v.code==='inline-candidate-unapproved'))gaps.push(gap('SCM-LOCATION','内联脚本仅取得候选,尚未确认实际仓库及分支','在jenkins-ops.json的jobMappings确认一次;不执行项目Jenkinsfile'));
58
+ if(bound){try{const xml=await client.config(row.job);assert(xml,'已登记Job不存在');const contract=jobContract(xml);assert(contract.script===nativePipelineEntry(c.nativeEntry).trim()&&contract.sandbox==='true'&&contract.disabled==='false','业务Job未使用批准公共入口或已禁用');if(row.job===bound.binding.jobs.check){const trigger=contract.triggers.find(v=>v.type==='com.dabsquared.gitlabjenkins.GitLabPushTrigger');assert(trigger?.fields.includeBranchesSpec===bound.binding.branch&&trigger.fields.triggerOnPush==='true','CHECK未配置目标分支Push触发');}t.entryVerified=true;}catch{gaps.push(gap('JOB-ENTRY','已登记Job的实际入口或目标分支触发未核验通过','重新核对受控入口与任务状态,不沿用旧绿灯'));}}
59
+ if(row.status==='unreadable')gaps.push(gap('JOB-UNREADABLE','任务配置未读到,无法核验接入状态','核对Jenkins范围内Read/ExtendedRead权限,再重新盘点'));
60
+ t.checkoutAllowed=!gaps.some(g=>g.id==='SCM-LOCATION')&&row.status!=='unreadable';
61
+ t.key=targetKey(t);targets.push(t);
62
+ }
63
+ return {schemaVersion:1,kind:'jenkins-ops-inventory',runId:randomUUID(),checkedAt:new Date().toISOString(),clusterHash:c.configHash,runtimeHash:gateRuntimeHash(),jenkinsOrigin:c.jenkinsOrigin,status:discovered.status,targets,errors:[...registryErrors,...(discovered.errors||[]).map(e=>gap('INVENTORY',e.reason||'任务未完整盘点','核对Jenkins只读权限及盘点预算后重跑'))],jobsDiscovered:discovered.jobs.length,remoteModified:false,qualityPassed:false,buildAuthorized:false};
64
+ }
65
+ export function inspectOpsProject({clusterFile,inventory,targetKey:key,root,failure}){
66
+ const {c,bindings}=opsContext(clusterFile);checkInventory(inventory,c);const target=inventory.targets.find(t=>t.key===key);assert(target,'目标不在本轮盘点内');
67
+ const out={...target,kind:'jenkins-ops-project',runId:inventory.runId,clusterHash:c.configHash,runtimeHash:gateRuntimeHash(),gaps:[...target.gaps],checkedAt:new Date().toISOString(),status:'attention',qualityPassed:false,buildAuthorized:false};
68
+ if(!target.checkoutAllowed)return out;
69
+ if(failure){out.gaps.push(gap('CHECKOUT','源码检出未成功','核对SCM只读凭据、分支及网络后复查;本轮未检查源码'));return out;}
70
+ assert(target.checkoutAllowed,'未确认仓库或分支,不能执行检出检查');
71
+ root=controlledPath(root);assert(git(['remote','get-url','origin'],root)===target.repository,'检出仓库与本轮盘点不一致');assert(actualSha(root)===git(['rev-parse','refs/remotes/origin/'+target.branch],root),'检出不是本轮固定目标分支');
72
+ out.sha=actualSha(root);
73
+ let manifest;try{manifest=JSON.parse(readFileSync(safePath(root,'quality/workspace.json')));assert(['frontend','backend'].includes(manifest.projectKind),'项目类型未确认');}catch{out.gaps.push(gap('PROJECT-GATE','项目尚未完整接入Gate:缺少有效quality/workspace.json','技术经理使用项目接入入口生成质量配置并提交,再自动复查'));}
74
+ if(manifest){
75
+ if(manifest.gateVersion!==GATE_VERSION)out.gaps.push(gap('GATE-VERSION','项目声明Gate版本与执行节点固定版不一致','按批准固定版本更新项目接入并重新登记'));
76
+ try{const d=doctor({root,kind:manifest.projectKind});out.projectKind=manifest.projectKind;out.dependenciesPreparedByCheck=d.checks.filter(v=>v.status!=='passed'&&/^(INSTALLED-|ENGINES-)/.test(v.id)).map(v=>v.id);for(const v of d.checks.filter(v=>v.status!=='passed'&&!/^(INSTALLED-|ENGINES-)/.test(v.id)))out.gaps.push(gap(v.id,v.message,'修复项目接入配置并提交;已批准且锁定的依赖由CHECK自动准备'));}catch{out.gaps.push(gap('PROJECT-CONFIG','项目接入文件不完整或不可解析','修复接入文件后复查,不执行业务命令'));}
77
+ for(const file of ['quality/review-policy.json','quality/review-evidence.plan.json'])try{const v=JSON.parse(readFileSync(safePath(root,file)));assert(v&&typeof v==='object','invalid');}catch{out.gaps.push(gap('REVIEW-FILE',file+'缺失或不可解析','完成当前项目九维证据计划和Review策略并提交'));}
78
+ }
79
+ const bound=bindings.find(b=>b.registryFile===target.registryFile&&b.binding.id===target.bindingId);
80
+ if(bound&&manifest){try{const source=assertBoundCheckout({binding:bound.binding,mode:'check'},root,{});out.sourceDigest=source.sourceDigest;out.pinsVerified=true;}catch{out.gaps.push(gap('APPROVED-PINS','当前项目与已批准策略、锁或证据摘要不一致','工程维护者审阅差异并重新生成登记,巡检不自动批准策略变化'));}}
81
+ if(bound&&target.job===bound.binding.jobs.check){out.checkJob=bound.binding.jobs.check;out.bindingHash=hash(bound.binding);}
82
+ out.status=out.gaps.length?'attention':'ready';return out;
83
+ }
84
+ const escape=s=>String(s??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');
85
+ export function opsReportHtml(r){return '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Jenkins接入巡检</title><style>body{font:15px/1.6 system-ui;max-width:1100px;margin:32px auto;padding:0 20px;color:#17283d}h1{font-size:24px}table{border-collapse:collapse;width:100%;margin:16px 0}th,td{border:1px solid #dde4ec;padding:10px;text-align:left;vertical-align:top}th{background:#f1f5f9}.warn{color:#b42318}small{color:#526479}section{margin:24px 0}code{background:#eef2f6;padding:2px 4px}</style><h1>Jenkins接入巡检</h1><p>'+escape(r.checkedAt)+' · '+escape(r.status)+' · '+r.projects.length+'项</p><p class="warn">本页核验接入条件。未接入、未检查或尚未受控均不代表质量通过;九维取证、人工Review与构建准入仍以当前SHA的正式记录为准。</p>'+r.globalGaps.map(g=>'<p class="warn">'+escape(g.message)+':'+escape(g.action)+'</p>').join('')+'<nav>'+r.projects.map(p=>'<a href="#'+escape(p.key)+'">'+escape(p.project)+' / '+escape(p.branch||'分支待确认')+'</a> ').join('')+'</nav>'+r.projects.map(p=>'<section id="'+escape(p.key)+'"><h2>'+escape(p.project)+' · '+escape(p.branch||'分支待确认')+'</h2><small>'+escape(p.jobs.join(','))+' · '+escape(p.sha||'源码未核验')+'</small><table><tr><th>缺项</th><th>处理与复查</th></tr>'+p.gaps.map(g=>'<tr><td>'+escape(g.id)+' · '+escape(g.message)+'</td><td>'+escape(g.action)+'</td></tr>').join('')+(p.gaps.length?'':'<tr><td>接入前置已核验</td><td>由受控CHECK执行自动依赖准备、质量检查和九维取证;不授予构建权限</td></tr>')+'</table></section>').join('')+'</html>';}
86
+ export async function finishOps({clusterFile,inventory,results=[],env=process.env,send=false,retryNotification=false,fetchImpl=fetch,now=new Date().toISOString()}){
87
+ const {c,o}=opsContext(clusterFile);checkInventory(inventory,c);const projects=[];
88
+ const archive=path.join(c.configDir,'ops-state/runs',inventory.runId),reportFile=path.join(archive,'report.json');
89
+ if(existsSync(reportFile)){const old=readControlledJson(reportFile);assert(old.clusterHash===c.configHash&&old.runId===inventory.runId,'已有巡检回执不属于当前集群,不覆盖');return old;}
90
+ for(const t of inventory.targets){const matches=results.filter(r=>r.key===t.key&&r.runId===inventory.runId&&r.clusterHash===c.configHash&&r.runtimeHash===gateRuntimeHash());let p;if(matches.length===1)p=matches[0];else p={...t,status:'attention',gaps:[...t.gaps,gap('INSPECTION-MISSING','本轮未取得唯一项目检出核验结果','检查本轮失败阶段后重跑;不会使用历史扫描报告')]};const owner=o.owners[p.project]||{};projects.push({...p,technicalManager:owner.technicalManager,executor:owner.executor});}
91
+ const readiness=inspectJenkinsReadiness({clusterFile:c.file}),globalGaps=[...inventory.errors];
92
+ if(!inventory.targets.length)globalGaps.push(gap('NO-TARGETS','本轮没有核验到目标项目','确认盘点目录权限、项目范围及分支,不把空结果当接入通过'));
93
+ for(const s of readiness.steps.filter(s=>s.status!=='passed'&&s.id!=='节点公共入口'))globalGaps.push(gap('CENTRAL-CONFIG',s.id+'未就绪','缺项通知可独立运行;实际九维检查、Review及续建需要对应批准配置'));
94
+ const claimsFile=path.join(c.configDir,'ops-state/checks.json');
95
+ if(existsSync(claimsFile)){const state=readControlledJson(claimsFile);for(const p of projects)if(Object.values(state.claims||{}).some(v=>v.status==='claimed'&&v.job===p.checkJob&&v.sha===p.sha&&v.bindingHash===p.bindingHash))p.gaps.push(gap('CHECK-DISPATCH-UNKNOWN','上轮CHECK启动结果尚未确认','核对实际CHECK运行后确认编号;未确认前不重复启动,也不放行构建'));for(const p of projects)if(p.gaps.length)p.status='attention';}
96
+ let runUrl;try{const u=new URL(env.BUILD_URL);assert(u.origin===new URL(c.jenkinsOrigin).origin&&u.href===new URL(jobPath(env.JOB_NAME)+env.BUILD_NUMBER+'/',c.jenkinsOrigin).href,'invalid');runUrl=u.href;}catch{}
97
+ const report={schemaVersion:1,kind:'jenkins-ops-report',runId:inventory.runId,clusterHash:c.configHash,checkedAt:now,jenkinsOrigin:c.jenkinsOrigin,...(runUrl?{runUrl}:{}),status:globalGaps.length||projects.some(p=>p.status!=='ready')?'attention':'ready',projects,globalGaps,counts:{projects:projects.length,attention:projects.filter(p=>p.status!=='ready').length,ready:projects.filter(p=>p.status==='ready').length},remoteModified:false,qualityPassed:false,buildAuthorized:false};
98
+ const sortedGaps=g=>[...g].sort((a,b)=>stableStringify(a).localeCompare(stableStringify(b)));
99
+ const fingerprint=hash({globalGaps:sortedGaps(globalGaps),projects:projects.map(p=>({key:p.key,status:p.status,gaps:sortedGaps(p.gaps)})).sort((a,b)=>a.key.localeCompare(b.key)),people:o.people,owners:o.owners,fallbackRecipients:o.fallbackRecipients});
100
+ const stateFile=path.join(c.configDir,'ops-state/state.json');
101
+ report.notification=await lockedJson(stateFile,{schemaVersion:1,kind:'jenkins-ops-state',notices:{}},async(state,checkpoint)=>{
102
+ if(state.observedFingerprint!==fingerprint){state.epoch=(state.epoch||0)+1;state.observedFingerprint=fingerprint;}
103
+ const eventKey=state.epoch+':'+fingerprint,previous=state.notices[eventKey];
104
+ if(!send)return {status:'not-requested'};
105
+ if(previous&&['sent','pending','unknown'].includes(previous.status)&&!retryNotification)return {status:previous.status==='sent'?'unchanged':'unknown',reason:'相同缺项不重复发送;未知送达先查上轮回执,需要补发时显式勾选RETRY_NOTIFICATION',priorRunId:previous.runId};
106
+ report.recovered=report.status==='ready'&&state.lastSentStatus==='attention';
107
+ if(report.status==='ready'&&!report.recovered)return {status:'not-required',reason:'无新增接入缺项;仍以正式检查与Review记录判定质量'};
108
+ state.notices[eventKey]={status:'pending',runId:inventory.runId,checkedAt:now};checkpoint();
109
+ const {sendOpsNotification}=await import('./jenkins-ops-notification.js');
110
+ let result;try{result=await sendOpsNotification(report,o,{env,fetchImpl,now:Date.parse(now)});}catch{result={status:'unknown',reason:'推送结果未确认,先核对群消息及上轮记录'};}
111
+ state.notices[eventKey]={status:result.status,runId:inventory.runId,checkedAt:now};if(result.status==='sent')state.lastSentStatus=report.status;
112
+ return result;
113
+ });
114
+ writeExclusiveJson(reportFile,report);
115
+ return report;
116
+ }
117
+ export async function runOpsCli(argv){
118
+ const action=argv.shift(),o={};assert(['inventory','inspect','finish','claim-check','ack-check'].includes(action),'运维入口支持inventory/inspect/finish/claim-check/ack-check');
119
+ while(argv.length){const k=argv.shift();assert(['--cluster','--out','--inventory','--target-key','--root','--failure','--results','--send','--retry-notification','--retry-unknown','--run-id','--build-number','--dispatch-id'].includes(k)&&!Object.hasOwn(o,k),'未知或重复运维巡检参数');if(['--send','--retry-notification','--retry-unknown'].includes(k))o[k]=true;else{const v=argv.shift();assert(v&&!v.startsWith('--'),'巡检参数缺值');o[k]=v;}}
120
+ assert(o['--cluster']&&o['--out'],'运维巡检需要当前集群配置与本轮输出路径');
121
+ let r;const clusterFile=path.resolve(o['--cluster']);
122
+ if(action==='claim-check'||action==='ack-check'){assert(action!=='ack-check'||o['--dispatch-id'],'记录实际CHECK必须绑定本次dispatchId');r=await (action==='claim-check'?claimOpsCheck:acknowledgeOpsCheck)({clusterFile,runId:o['--run-id'],key:o['--target-key'],buildNumber:o['--build-number'],dispatchId:o['--dispatch-id'],retryUnknown:o['--retry-unknown']===true});}
123
+ else if(action==='inventory')r=await inventoryOps({clusterFile});
124
+ else{assert(o['--inventory'],'需要本轮盘点文件');const inventory=readControlledJson(path.resolve(o['--inventory']));if(action==='inspect')r=inspectOpsProject({clusterFile,inventory,targetKey:o['--target-key'],root:o['--root'],failure:o['--failure']});else{assert(o['--results'],'需要本轮核验结果目录');const dir=controlledPath(path.resolve(o['--results']));const results=existsSync(dir)?readdirSync(dir).filter(f=>f.endsWith('.json')).map(f=>readControlledJson(path.join(dir,f))):[];r=await finishOps({clusterFile,inventory,results,send:o['--send'],retryNotification:o['--retry-notification']===true});}}
125
+ const file=controlledPath(path.resolve(o['--out']));if(existsSync(file))assert(stableStringify(readControlledJson(file))===stableStringify(r),'输出文件不是本轮原件,不覆盖');else writeExclusiveJson(file,r);
126
+ if(action==='finish'){const {writeFileSync}=await import('node:fs');const html=controlledPath(path.join(path.dirname(file),'index.html')),text=opsReportHtml(r);if(existsSync(html))assert(readFileSync(html,'utf8')===text,'详情页不是本轮原件,不覆盖');else writeFileSync(html,text,{flag:'wx',mode:0o600});}
127
+ console.log('运维巡检:'+r.status+';本轮结果:'+path.basename(file)+(r.notification?';飞书:'+r.notification.status:''));
128
+ return action==='finish'&&(r.status!=='ready'||o['--send']&&!['sent','unchanged','not-required'].includes(r.notification.status))?2:0;
129
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@agile-team/wl-skills-gate",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@agile-team/wl-skills-gate",
9
- "version": "0.13.0",
9
+ "version": "0.13.2",
10
10
  "license": "UNLICENSED",
11
11
  "dependencies": {
12
12
  "@secretlint/node": "13.0.7",
@@ -1194,7 +1194,7 @@
1194
1194
  "tapable": "^2.3.3"
1195
1195
  },
1196
1196
  "engines": {
1197
- "node": ">=10.13.0"
1197
+ "node": ">=10.13.1"
1198
1198
  }
1199
1199
  },
1200
1200
  "node_modules/entities": {
@@ -1613,7 +1613,7 @@
1613
1613
  "is-glob": "^4.0.3"
1614
1614
  },
1615
1615
  "engines": {
1616
- "node": ">=10.13.0"
1616
+ "node": ">=10.13.1"
1617
1617
  }
1618
1618
  },
1619
1619
  "node_modules/global-directory": {
@@ -1728,7 +1728,7 @@
1728
1728
  "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==",
1729
1729
  "license": "MIT",
1730
1730
  "engines": {
1731
- "node": ">=10.13.0"
1731
+ "node": ">=10.13.1"
1732
1732
  }
1733
1733
  },
1734
1734
  "node_modules/is-core-module": {
@@ -2433,7 +2433,7 @@
2433
2433
  "resolve": "^1.20.0"
2434
2434
  },
2435
2435
  "engines": {
2436
- "node": ">= 10.13.0"
2436
+ "node": ">= 10.13.1"
2437
2437
  }
2438
2438
  },
2439
2439
  "node_modules/regexp-tree": {
@@ -2910,7 +2910,7 @@
2910
2910
  "tsconfig-paths": "^4.1.2"
2911
2911
  },
2912
2912
  "engines": {
2913
- "node": ">=10.13.0"
2913
+ "node": ">=10.13.1"
2914
2914
  }
2915
2915
  },
2916
2916
  "node_modules/type-check": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agile-team/wl-skills-gate",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "质量工程执行包 — 统一接入、九维Review证据授权、自动门禁、可信CI采集与分类治理通知",
5
5
  "type": "module",
6
6
  "main": "./lib/run.js",
@@ -113,7 +113,10 @@ test('贯通:实际扫描/脚本修复→九维授权→自动续建→真实
113
113
  const id=dispatched.sent[0].id,env={...process.env,...j.environment('BUILD'),...fakeEnv,WL_REVIEW_SHA:sha,WL_GATE_DISPATCH_ID:id};
114
114
  await runJenkinsAction({action:'prepare',registryFile:j.registryFile,root:j.root,env,runner:c.runner,now});
115
115
  const admit=await runJenkinsAction({action:'admit',registryFile:j.registryFile,root:j.root,env,now,verifyReview:options=>selectReleaseReview({...options,fetchImpl:c.fetchImpl})});
116
- const built=await runJenkinsAction({action:'build',registryFile:j.registryFile,root:j.root,env,admissionHash:admit.admissionHash,runner:c.runner,now:now+1000});assert.equal(built.exitCode,0);assert.equal(built.approvedForDeployment,false);
116
+ // Admission performs real verification work. Advance the simulated build
117
+ // clock from its actual receipt rather than assuming it finished in 1s.
118
+ const buildTime=Date.parse(json(path.join(j.root,'quality-reports/ci-integration/admission.json')).createdAt)+1000;
119
+ const built=await runJenkinsAction({action:'build',registryFile:j.registryFile,root:j.root,env,admissionHash:admit.admissionHash,runner:c.runner,now:buildTime});assert.equal(built.exitCode,0);assert.equal(built.approvedForDeployment,false);
117
120
  const result=json(path.join(j.root,BUILD_RESULT_PATH)),zip=readFileSync(path.join(j.root,BUILD_ARCHIVE_PATH));assert.equal(result.artifacts[0].sha256,digest(zip));assert.equal(result.commitSha,sha);
118
121
  const zipTest=spawnSync('python3',['-c','import sys,zipfile;z=zipfile.ZipFile(sys.argv[1]);assert z.testzip() is None;assert z.read("dist/app.txt")==b"canary built from approved source"',path.join(j.root,BUILD_ARCHIVE_PATH)],{encoding:'utf8'});assert.equal(zipTest.status,0,zipTest.stderr);
119
122
  const ci=path.join(j.base,'build-source.json'),request=put(j.base,'request.json',{sha,runId:12,sequence:120001,authorId:'author'}),jobUrl=env.BUILD_URL;
@@ -0,0 +1,96 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {discoverJenkinsJobs} from '../../lib/jenkins-inventory.js';
4
+
5
+ const origin='https://jenkins.example.invalid/',env={WL_JENKINS_READ_USER:'reader',WL_JENKINS_READ_TOKEN:'inventory-private-token'};
6
+ const esc=value=>value.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
7
+ const flow=script=>`<flow-definition><definition class="org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition"><script>${esc(script)}</script><sandbox>true</sandbox></definition></flow-definition>`;
8
+ const git=(repository='https://gitlab.example.invalid/team/wl-produce.git',branch='*/test',credential='Gitlab')=>`<scm class="hudson.plugins.git.GitSCM"><userRemoteConfigs><hudson.plugins.git.UserRemoteConfig><url>${esc(repository)}</url><credentialsId>${esc(credential)}</credentialsId></hudson.plugins.git.UserRemoteConfig></userRemoteConfigs><branches><hudson.plugins.git.BranchSpec><name>${esc(branch)}</name></hudson.plugins.git.BranchSpec></branches></scm>`;
9
+ function fixture({jobs={},lists={},fail={}}={}){
10
+ const calls=[];
11
+ const fetchImpl=async(url,options)=>{
12
+ const u=new URL(url);calls.push({url,method:options.method});assert.equal(options.method,'GET');assert.equal(options.redirect,'error');assert.equal(options.headers.Authorization,'Basic '+Buffer.from(env.WL_JENKINS_READ_USER+':'+env.WL_JENKINS_READ_TOKEN).toString('base64'));assert.equal(u.origin,new URL(origin).origin);
13
+ const parts=u.pathname.split('/').filter(Boolean);let p=0;const names=[];while(parts[p]==='job'){names.push(decodeURIComponent(parts[p+1]));p+=2;}const name=names.join('/'),suffix=parts.slice(p).join('/');
14
+ if(fail[name+':'+suffix])return new Response('',{status:fail[name+':'+suffix]});
15
+ if(suffix==='api/json')return new Response(JSON.stringify({jobs:lists[name]||Object.keys(jobs).filter(n=>!n.includes('/')).map(n=>({name:n,_class:'org.jenkinsci.plugins.workflow.job.WorkflowJob'}))}));
16
+ if(suffix==='config.xml')return Object.hasOwn(jobs,name)?new Response(jobs[name]):new Response('',{status:404});
17
+ throw Error('unexpected mock route');
18
+ };
19
+ return {calls,run:inventory=>discoverJenkinsJobs({jenkinsOrigin:origin,inventory,env,fetchImpl})};
20
+ }
21
+
22
+ test('初次盘点无登记或中央文件:解析GitSCM,只发GET且不回显配置/秘密',async()=>{
23
+ const f=fixture({jobs:{'生产-test':`<flow-definition><definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition">${git()}</definition><secretToken>do-not-leak-secret</secretToken></flow-definition>`}}),r=await f.run();
24
+ assert.equal(r.kind,'jenkins-ops-inventory');assert.equal(r.status,'inventoried');assert.equal(r.remoteModified,false);assert.deepEqual(r.errors,[]);assert.equal(r.jobs[0].status,'read');assert.equal(r.jobs[0].managed,false);assert.match(r.jobs[0].configHash,/^[a-f0-9]{64}$/);assert.deepEqual(r.jobs[0].scmCandidates,[{repository:'https://gitlab.example.invalid/team/wl-produce.git',credentialId:'Gitlab',branches:['*/test']}]);assert.doesNotMatch(JSON.stringify(r),/do-not-leak-secret|inventory-private-token|<scm|<flow-definition>|script/);assert.ok(f.calls.every(c=>c.method==='GET'));
25
+ });
26
+
27
+ test('XML参数分支仅为候选,默认值不能成为本轮分支',async()=>{
28
+ const xml=`<project><properties><hudson.model.ParametersDefinitionProperty><parameterDefinitions><hudson.model.StringParameterDefinition><name>BRANCH</name><defaultValue>sit</defaultValue></hudson.model.StringParameterDefinition><hudson.model.StringParameterDefinition><name>PASSWORD</name><defaultValue>not-output</defaultValue></hudson.model.StringParameterDefinition></parameterDefinitions></hudson.model.ParametersDefinitionProperty></properties>${git(undefined,'${BRANCH}')}</project>`;
29
+ const r=await fixture({jobs:{dynamic:xml}}).run(),j=r.jobs[0];assert.equal(r.status,'attention');assert.deepEqual(j.scmCandidates[0].branches,[]);assert.equal(j.scmCandidates[0].dynamicBranch,'BRANCH');assert.deepEqual(j.parameterDefaults,[{name:'BRANCH',defaultValue:'sit'}]);assert.ok(j.discoveryGaps.some(g=>g.code==='dynamic-branch'));assert.doesNotMatch(JSON.stringify(r),/PASSWORD|not-output/);
30
+ });
31
+
32
+ test('真实内联风格:严格环境字面值和env.TAG被识别,不执行Groovy',async()=>{
33
+ const script=`pipeline {
34
+ environment {
35
+ GIT_URL = 'git@gitlab.nisco.cn:YanTaiHuaXin-xxh/wl-ui.git'
36
+ GIT_CREDENTIAL_ID = 'Gitlab'
37
+ }
38
+ stages { stage('checkout') { steps { script {
39
+ git branch: env.TAG,
40
+ credentialsId: env.GIT_CREDENTIAL_ID,
41
+ url: env.GIT_URL
42
+ sh 'touch /tmp/never-run-by-inventory'
43
+ } } } }
44
+ }`;
45
+ const r=await fixture({jobs:{inline:flow(script)}}).run(),j=r.jobs[0];assert.equal(j.status,'attention');assert.deepEqual(j.scmCandidates,[{repository:'git@gitlab.nisco.cn:YanTaiHuaXin-xxh/wl-ui.git',credentialId:'Gitlab',branches:[],dynamicBranch:'TAG'}]);assert.ok(j.discoveryGaps.some(g=>g.code==='inline-candidate-unapproved'));assert.doesNotMatch(JSON.stringify(r),/touch|never-run|stages/);
46
+ });
47
+
48
+ test('内联git字面分支可识别,注释和字符串内假检出不被当成代码',async()=>{
49
+ const script=String.raw`// git branch: 'prd', url: 'https://attacker.invalid/a.git'
50
+ /* environment { GIT_URL = 'https://attacker.invalid/b.git' } */
51
+ def text = "git branch: 'main', url: 'https://attacker.invalid/c.git'"
52
+ def regexp = /git branch: 'main', url: 'https:\/\/attacker.invalid\/d.git'/
53
+ git(branch: 'test', credentialsId: 'Gitlab', url: 'https://gitlab.example.invalid/a.git')`;
54
+ const j=(await fixture({jobs:{inline:flow(script)}}).run()).jobs[0];assert.deepEqual(j.scmCandidates,[{repository:'https://gitlab.example.invalid/a.git',credentialId:'Gitlab',branches:['test']}]);assert.doesNotMatch(JSON.stringify(j),/attacker/);
55
+ });
56
+
57
+ test('插值/函数/拼接/重复环境值/复杂参数不会被猜成仓库或分支',async()=>{
58
+ const scripts=[
59
+ `git branch: chooseBranch(), url: 'https://gitlab.example.invalid/a.git'`,
60
+ `git branch: 'test', url: "https://gitlab.example.invalid/\${env.PROJECT}.git"`,
61
+ `environment {\n GIT_URL = 'https://gitlab.example.invalid/a.git' + suffix\n}\ngit branch: 'test', url: env.GIT_URL`,
62
+ `environment {\n GIT_URL = 'https://gitlab.example.invalid/a.git'\n GIT_URL = 'https://gitlab.example.invalid/b.git'\n}\ngit branch: 'test', url: env.GIT_URL`,
63
+ `git branch: params.BRANCH, url: 'https://gitlab.example.invalid/a.git'`,
64
+ `git branch: env.TAG ?: 'test', url: 'https://gitlab.example.invalid/a.git'`,
65
+ `git branch: '', url: 'https://gitlab.example.invalid/a.git'`,
66
+ ];
67
+ for(const script of scripts){const j=(await fixture({jobs:{invalid:flow(script)}}).run()).jobs[0];assert.equal(j.status,'attention');assert.deepEqual(j.scmCandidates,[]);assert.ok(j.discoveryGaps.length);}
68
+ });
69
+
70
+ test('URL中账号/密码、动态URL与多仓库歧义均保留缺口,不泄漏秘密',async()=>{
71
+ const samples=[git('https://user:topsecret@gitlab.example.invalid/a.git'),git('https://bearer@gitlab.example.invalid/a.git'),git('https://gitlab.example.invalid/${PROJECT}.git'),git().replace('</userRemoteConfigs>','<hudson.plugins.git.UserRemoteConfig><url>https://gitlab.example.invalid/second.git</url></hudson.plugins.git.UserRemoteConfig></userRemoteConfigs>')];
72
+ for(const sample of samples){const r=await fixture({jobs:{invalid:`<project>${sample}</project>`}}).run();assert.equal(r.status,'attention');assert.deepEqual(r.jobs[0].scmCandidates,[]);assert.doesNotMatch(JSON.stringify(r),/topsecret|bearer@|\$\{PROJECT\}|second\.git/);}
73
+ const r=await fixture({jobs:{ambiguous:`<project>${git()}${git('https://gitlab.example.invalid/other.git')}</project>`}}).run();assert.deepEqual(r.jobs[0].scmCandidates,[]);assert.ok(r.jobs[0].discoveryGaps.some(g=>g.code==='ambiguous-repository'));
74
+ });
75
+
76
+ test('中文Folder递归、重复根去重,文件夹深度预算明确报不完整',async()=>{
77
+ const lists={'':[{name:'生产',_class:'com.cloudbees.hudson.plugins.folder.Folder'}],'生产':[{name:'前端',_class:'org.jenkinsci.plugins.workflow.job.WorkflowJob'},{name:'更深',_class:'com.cloudbees.hudson.plugins.folder.Folder'}],'生产/更深':[{name:'后端',_class:'org.jenkinsci.plugins.workflow.job.WorkflowJob'}]},jobs={'生产/前端':`<project>${git()}</project>`,'生产/更深/后端':`<project>${git()}</project>`};
78
+ const f=fixture({jobs,lists}),r=await f.run({roots:['','生产'],maxDepth:8,maxJobs:20});assert.equal(r.status,'inventoried');assert.deepEqual(r.jobs.map(j=>j.job),['生产/前端','生产/更深/后端']);assert.equal(f.calls.filter(c=>new URL(c.url).pathname==='/job/'+encodeURIComponent('生产')+'/api/json').length,1);
79
+ const limited=await fixture({jobs,lists}).run({maxDepth:1});assert.equal(limited.status,'attention');assert.ok(limited.errors.some(e=>e.code==='depth-budget-exceeded'));assert.equal(limited.jobs.length,1);
80
+ });
81
+
82
+ test('数量预算、非法任务、列表403和任务404各自可追溯,其他任务继续',async()=>{
83
+ const jobs={ok:`<project>${git()}</project>`},lists={'':[{name:'ok',_class:'hudson.model.FreeStyleProject'},{name:'gone',_class:'hudson.model.FreeStyleProject'},{name:'denied',_class:'hudson.model.FreeStyleProject'},{name:'bad/name',_class:'hudson.model.FreeStyleProject'},{name:'folder',_class:'com.cloudbees.hudson.plugins.folder.Folder'}]},fail={'denied:config.xml':403,'folder:api/json':403};
84
+ const r=await fixture({jobs,lists,fail}).run();assert.equal(r.status,'attention');assert.equal(r.jobs.find(j=>j.job==='ok').status,'read');assert.match(r.jobs.find(j=>j.job==='gone').discoveryGaps[0].message,/404/);assert.match(r.jobs.find(j=>j.job==='denied').discoveryGaps[0].message,/403/);assert.ok(r.errors.some(e=>e.code==='invalid-job'));assert.ok(r.errors.some(e=>e.code==='root-unreadable'&&/403/.test(e.reason)));
85
+ const limited=await fixture({jobs,lists}).run({maxJobs:1});assert.equal(limited.jobs.length,1);assert.ok(limited.errors.some(e=>e.code==='job-budget-exceeded'));
86
+ });
87
+
88
+ test('畸形/DTD XML保持不可读;无SCM标未知而非默认批准',async()=>{
89
+ for(const xml of ['<project>','<!DOCTYPE x><project/>','<project/>']){const j=(await fixture({jobs:{unknown:xml}}).run()).jobs[0];assert.notEqual(j.status,'read');assert.deepEqual(j.scmCandidates,[]);assert.ok(j.discoveryGaps.length);}
90
+ });
91
+
92
+ test('输入与凭据预检在发送请求前失败,不调用写API',async()=>{
93
+ let calls=0;const fetchImpl=()=>{calls++;throw Error('must not call');};
94
+ for(const inventory of [{maxJobs:0},{maxDepth:20},{roots:['../escape']},{roots:[]},{unknown:true}])await assert.rejects(discoverJenkinsJobs({jenkinsOrigin:origin,inventory,env,fetchImpl}));
95
+ await assert.rejects(discoverJenkinsJobs({jenkinsOrigin:origin,env:{},fetchImpl}),/凭据|账号/);await assert.rejects(discoverJenkinsJobs({jenkinsOrigin:'https://reader:secret@jenkins.example.invalid',env,fetchImpl}));assert.equal(calls,0);
96
+ });
@@ -16,9 +16,12 @@ const environment=(job='wl-quality-fleet')=>({JENKINS_URL:'https://jenkins.examp
16
16
  test('服务器公共入口随Gate安装,零新仓库;重复安装无差异且修改须重新预览',t=>{
17
17
  const f=fixture(t),v=installed(f);assert.match(v.result.pipelineScript,/node\('wl-quality-router'\).*load\(.*entry.groovy.*\) \}\.call\(\)/);assert(!v.result.remoteModified);assert(existsSync(v.result.nativeEntry.loaderFile));assert(planJenkinsInstallation(v.input).files.every(e=>e.action==='unchanged'));assert.throws(()=>applyJenkinsInstallation({...v.input,confirm:true,planHash:v.p.planHash}),/差异计划/);assert.throws(()=>planJenkinsInstallation({...v.input,libraryManifestFile:'unused'}),/未知/);
18
18
  const alias=spawnSync(v.result.command,['--version'],{encoding:'utf8'});assert.equal(alias.status,0,alias.stderr);assert.equal(alias.stdout.split(' ')[1],GATE_VERSION);
19
+ const minimal=spawnSync(v.result.command,['--version'],{encoding:'utf8',env:{...process.env,PATH:'/usr/bin:/bin'}});assert.equal(minimal.status,0,minimal.stderr);assert.equal(minimal.stdout.split(' ')[1],GATE_VERSION);
20
+ assert.equal(v.result.nodeEnvironment.WL_NODE_BIN_DIR,path.dirname(process.execPath));const cluster=JSON.parse(readFileSync(v.result.clusterFile));assert.equal(cluster.nodeBinDir,path.dirname(process.execPath));assert(!readFileSync(v.result.nativeEntry.loaderFile,'utf8').includes('__WL_NODE_BIN_DIR__'));assert(readFileSync(v.result.nativeEntry.loaderFile,'utf8').includes(path.dirname(process.execPath)));
19
21
  });
20
22
  test('路由按真实Job识别维护角色,拒绝冒充签发者和入口漂移',async t=>{
21
23
  const f=fixture(t),v=installed(f),args={configFile:v.result.clusterFile,env:environment(),verifyEntry:true};const c=await resolveJenkinsContext(args);assert.equal(c.role,'fleet');assert.equal(c.agentLabel,'central');assert(c.entryVerified);await assert.rejects(resolveJenkinsContext({...args,role:'review'}),/不能借/);writeFileSync(v.result.nativeEntry.loaderFile,'changed');await assert.rejects(resolveJenkinsContext(args),/入口被修改/);
24
+ assert.equal(c.nodeBinDir,path.dirname(process.execPath));
22
25
  });
23
26
  test('业务Job按登记路由,公共入口和当前包字节都须一致',async t=>{
24
27
  const f=fixture(t);await bind(f);const v=installed(f),r=JSON.parse(readFileSync(f.registryFile));r.nativeEntry=v.result.nativeEntry;put(f.base,'bindings.json',r);const c=await resolveJenkinsContext({configFile:v.result.clusterFile,env:f.environment('CHECK'),verifyEntry:true});assert.equal(c.role,'job');assert.equal(c.registry,f.registryFile);assert.equal(renderJenkinsEntry(r,f.registryFile),v.result.pipelineScript);await assert.rejects(resolveJenkinsContext({role:'review',configFile:v.result.clusterFile,env:f.environment('CHECK')}),/不能借/);