|
1 | 1 | 'use strict' |
2 | 2 |
|
3 | 3 | const crypto = require('crypto') |
| 4 | +const { execFileSync } = require('child_process') |
4 | 5 | const fs = require('fs') |
5 | 6 | const path = require('path') |
6 | 7 |
|
@@ -93,25 +94,92 @@ function classifyConsumer(relativePath) { |
93 | 94 | return 'current' |
94 | 95 | } |
95 | 96 |
|
| 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 | + */ |
96 | 136 | function listConsumerDocuments(root) { |
97 | | - const excludedTop = new Set(['.git', '.devcodex', 'coverage', 'dist', 'node_modules', 'skills']) |
| 137 | + const tracked = gitLsFiles(root) |
98 | 138 | 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) |
106 | 146 | } |
| 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) |
107 | 160 | } |
108 | | - visit(root) |
109 | | - return files.sort().map(file => ({ |
| 161 | + |
| 162 | + return files.sort((a, b) => a.localeCompare(b)).map(file => ({ |
110 | 163 | path: normalizePath(root, file), |
111 | 164 | content: fs.readFileSync(file, 'utf8') |
112 | 165 | })) |
113 | 166 | } |
114 | 167 |
|
| 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 | + |
115 | 183 | function percentile(values, ratio) { |
116 | 184 | if (!values.length) return 0 |
117 | 185 | const sorted = [...values].sort((a, b) => a - b) |
@@ -234,9 +302,7 @@ function buildPortfolio(root) { |
234 | 302 | throw new Error('invalid skills/portfolio-evidence.json header') |
235 | 303 | } |
236 | 304 | 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) |
240 | 306 | const knownNames = new Set(skillFiles.map(file => path.basename(path.dirname(file)))) |
241 | 307 | const consumers = listConsumerDocuments(root) |
242 | 308 | const sourceRows = [] |
@@ -429,6 +495,10 @@ module.exports = { |
429 | 495 | canonicalizeTextForDigest, |
430 | 496 | collectDependencies, |
431 | 497 | detectCycles, |
| 498 | + gitLsFiles, |
| 499 | + isPortfolioConsumerExcluded, |
| 500 | + listConsumerDocuments, |
| 501 | + listSkillMarkdownFiles, |
432 | 502 | parseFrontmatter, |
433 | 503 | serializePortfolio, |
434 | 504 | validatePortfolio |
|
0 commit comments