Skip to content

Commit 1f8b544

Browse files
committed
fix: make Skill portfolio scan git-tracked only for V92 CI parity
Stop listConsumerDocuments full-tree walks that pull untracked clutter into consumers. Scan git ls-files only, regenerate portfolio, and add untracked pollution regression test so clean CI matches local.
1 parent 6dc3c9a commit 1f8b544

6 files changed

Lines changed: 6330 additions & 17130 deletions

File tree

‎changelogs/releases/v1.15.1.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
## 变更
1212

1313
- `ENTRY_MODULE_LINE_BUDGETS`:V93 与 `test-control-plane-module-contracts` 单源共享(index 预算 460)
14+
- **V92 根治**:`listConsumerDocuments` 仅扫描 **git-tracked** 文本文件,脏树 untracked 杂项不再污染 portfolio;干净 CI 与本地一致
1415
- `ComponentTransparencyTopologyGate` + `classifyComponentTransparencyTopology`(V97 / brand tests)
1516
- PI-119:自引入 CI 主动修 + commit/push 当前消息授权(execution-contract / release-verification R6)
1617
- GR-044:`ValidationLifecycleTraceabilityGate`(audit-requirements / review-checklist)

‎scripts/generate-skill-portfolio.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ if (check) {
2424
const current = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, 'utf8') : ''
2525
if (current !== desired) {
2626
console.error('Skill portfolio is stale. Run: node scripts/generate-skill-portfolio.js')
27+
console.error('V92 parity: consumers/skills are git-tracked only (untracked files ignored).')
2728
process.exit(1)
2829
}
2930
console.log(`✓ Skill portfolio is deterministic and current (${portfolio.summary.skillCount} skills)`)
3031
} else {
3132
fs.writeFileSync(OUTPUT, desired)
3233
console.log(`✓ Wrote ${path.relative(ROOT, OUTPUT)} (${portfolio.summary.skillCount} skills)`)
34+
console.log(' source=git-tracked-files-only (CI clean-checkout parity)')
3335
}

‎scripts/lib/skill-portfolio-utils.js‎

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const crypto = require('crypto')
4+
const { execFileSync } = require('child_process')
45
const fs = require('fs')
56
const path = require('path')
67

@@ -93,25 +94,92 @@ function classifyConsumer(relativePath) {
9394
return 'current'
9495
}
9596

97+
function gitLsFiles(root, pathspecs = []) {
98+
try {
99+
const args = ['-C', root, 'ls-files', '-z', '--']
100+
if (pathspecs.length) args.push(...pathspecs)
101+
else args.push('.')
102+
const out = execFileSync('git', args, {
103+
encoding: 'buffer',
104+
maxBuffer: 64 * 1024 * 1024,
105+
windowsHide: true
106+
})
107+
return out.toString('utf8').split('\0').filter(Boolean).map(rel => rel.replace(/\\/g, '/'))
108+
} catch {
109+
return null
110+
}
111+
}
112+
113+
function isPortfolioConsumerExcluded(relativePath) {
114+
const rel = relativePath.replace(/\\/g, '/')
115+
const excludedPrefixes = [
116+
'skills/',
117+
'node_modules/',
118+
'coverage/',
119+
'dist/',
120+
'.git/',
121+
'.devcodex/',
122+
'website/doc_build/',
123+
'website/dist/'
124+
]
125+
if (excludedPrefixes.some(prefix => rel === prefix.slice(0, -1) || rel.startsWith(prefix))) return true
126+
const base = path.posix.basename(rel)
127+
if (base === 'portfolio.json' || base === 'portfolio-evidence.json') return true
128+
return !TEXT_EXTENSIONS.has(path.extname(rel).toLowerCase())
129+
}
130+
131+
/**
132+
* Consumer scan for Skill portfolio.
133+
* MUST use git-tracked paths only when git is available so dirty/untracked worktrees
134+
* match CI clean checkouts (V92). Untracked reports/tmp/backup files must never change consumers.
135+
*/
96136
function listConsumerDocuments(root) {
97-
const excludedTop = new Set(['.git', '.devcodex', 'coverage', 'dist', 'node_modules', 'skills'])
137+
const tracked = gitLsFiles(root)
98138
const files = []
99-
function visit(dir, depth = 0) {
100-
if (!fs.existsSync(dir)) return
101-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
102-
if (entry.isDirectory() && ((depth === 0 && excludedTop.has(entry.name)) || entry.name === 'node_modules' || entry.name === 'dist')) continue
103-
const full = path.join(dir, entry.name)
104-
if (entry.isDirectory()) visit(full, depth + 1)
105-
else if (TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full)
139+
140+
if (tracked && tracked.length) {
141+
for (const rel of tracked) {
142+
if (isPortfolioConsumerExcluded(rel)) continue
143+
const full = path.join(root, rel)
144+
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue
145+
files.push(full)
106146
}
147+
} else {
148+
// Fallback for non-git unpack / pack install smoke: filesystem walk with same exclusions.
149+
const excludedTop = new Set(['.git', '.devcodex', 'coverage', 'dist', 'node_modules', 'skills', 'doc_build'])
150+
function visit(dir, depth = 0) {
151+
if (!fs.existsSync(dir)) return
152+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
153+
if (entry.isDirectory() && ((depth === 0 && excludedTop.has(entry.name)) || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'doc_build')) continue
154+
const full = path.join(dir, entry.name)
155+
if (entry.isDirectory()) visit(full, depth + 1)
156+
else if (TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full)
157+
}
158+
}
159+
visit(root)
107160
}
108-
visit(root)
109-
return files.sort().map(file => ({
161+
162+
return files.sort((a, b) => a.localeCompare(b)).map(file => ({
110163
path: normalizePath(root, file),
111164
content: fs.readFileSync(file, 'utf8')
112165
}))
113166
}
114167

168+
/** List SKILL.md paths: git-tracked only when available (ignore untracked skill drafts). */
169+
function listSkillMarkdownFiles(root) {
170+
const tracked = gitLsFiles(root, ['skills'])
171+
if (tracked && tracked.length) {
172+
return tracked
173+
.filter(rel => rel.replace(/\\/g, '/').endsWith('/SKILL.md') || /^skills\/[^/]+\/SKILL\.md$/.test(rel.replace(/\\/g, '/')))
174+
.map(rel => path.join(root, rel))
175+
.filter(full => fs.existsSync(full) && fs.statSync(full).isFile())
176+
.sort((a, b) => a.localeCompare(b))
177+
}
178+
return walk(path.join(root, 'skills'))
179+
.filter(file => path.basename(file) === 'SKILL.md')
180+
.sort((a, b) => a.localeCompare(b))
181+
}
182+
115183
function percentile(values, ratio) {
116184
if (!values.length) return 0
117185
const sorted = [...values].sort((a, b) => a - b)
@@ -234,9 +302,7 @@ function buildPortfolio(root) {
234302
throw new Error('invalid skills/portfolio-evidence.json header')
235303
}
236304
const registered = new Map((plugin.skills || []).map(item => [item.id, item]))
237-
const skillFiles = walk(path.join(root, 'skills'))
238-
.filter(file => path.basename(file) === 'SKILL.md')
239-
.sort()
305+
const skillFiles = listSkillMarkdownFiles(root)
240306
const knownNames = new Set(skillFiles.map(file => path.basename(path.dirname(file))))
241307
const consumers = listConsumerDocuments(root)
242308
const sourceRows = []
@@ -429,6 +495,10 @@ module.exports = {
429495
canonicalizeTextForDigest,
430496
collectDependencies,
431497
detectCycles,
498+
gitLsFiles,
499+
isPortfolioConsumerExcluded,
500+
listConsumerDocuments,
501+
listSkillMarkdownFiles,
432502
parseFrontmatter,
433503
serializePortfolio,
434504
validatePortfolio

‎scripts/test-skill-portfolio.js‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
canonicalizeTextForDigest,
1111
collectDependencies,
1212
detectCycles,
13+
listConsumerDocuments,
1314
serializePortfolio,
1415
validatePortfolio
1516
} = require('./lib/skill-portfolio-utils')
@@ -19,6 +20,31 @@ const first = buildPortfolio(ROOT)
1920
const second = buildPortfolio(ROOT)
2021

2122
assert.strictEqual(serializePortfolio(first), serializePortfolio(second), 'portfolio generation must be byte-identical')
23+
// V92 parity: consumers must come from git-tracked paths only when git is available.
24+
const consumers = listConsumerDocuments(ROOT)
25+
assert.ok(consumers.length > 50, 'expected a non-trivial tracked consumer set')
26+
assert.ok(!consumers.some(item => item.path.startsWith('skills/')), 'skills/ must not be scanned as consumers')
27+
assert.ok(!consumers.some(item => item.path.includes('.devcodex/')), '.devcodex must not be scanned as consumers')
28+
29+
// Untracked pollution must not change portfolio serialization (CI clean parity).
30+
const fs = require('fs')
31+
const pollution = path.join(ROOT, `_portfolio_pollution_${process.pid}.md`)
32+
const beforePollution = serializePortfolio(first)
33+
fs.writeFileSync(pollution, [
34+
'# pollution',
35+
'accessibility-i18n intent memory load-profile brand-visual-quality execution-contract',
36+
'This untracked file must not become a Skill consumer.'
37+
].join('\n'), 'utf8')
38+
try {
39+
const polluted = buildPortfolio(ROOT)
40+
assert.strictEqual(
41+
serializePortfolio(polluted),
42+
beforePollution,
43+
'untracked files must not change Skill portfolio (V92 clean-checkout parity)'
44+
)
45+
} finally {
46+
fs.unlinkSync(pollution)
47+
}
2248
assert.strictEqual(canonicalizeTextForDigest('a\r\nb\rc\n'), 'a\nb\nc\n', 'portfolio digests must canonicalize CRLF/CR/LF')
2349
assert.strictEqual(first.summary.skillCount, 78)
2450
assert.strictEqual(first.schemaVersion, 2)

0 commit comments

Comments
 (0)