mustflow 2.103.10 → 2.103.12

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,150 @@
1
+ import { printUsageError, renderHelp } from '../lib/cli-output.js';
2
+ import { t } from '../lib/i18n.js';
3
+ import { formatCliOptionParseError, getParsedCliStringOption, hasCliOptionToken, hasParsedCliOption, parseCliOptions, } from '../lib/option-parser.js';
4
+ import { resolveMustflowRoot } from '../lib/project-root.js';
5
+ import { inspectSecurityPatternScan, SECURITY_PATTERN_SCAN_SCRIPT_REF, } from '../../core/security-pattern-scan.js';
6
+ const SECURITY_PATTERN_SCAN_OPTIONS = [
7
+ { name: '--json', kind: 'boolean' },
8
+ { name: '--max-files', kind: 'string' },
9
+ { name: '--max-file-bytes', kind: 'string' },
10
+ { name: '--max-findings', kind: 'string' },
11
+ ];
12
+ function parsePositiveInteger(value, option, lang) {
13
+ if (value === null) {
14
+ return { value: null };
15
+ }
16
+ if (!/^[1-9]\d*$/u.test(value)) {
17
+ return { value: null, error: t(lang, 'securityPatternScan.error.invalidPositiveInteger', { option, value }) };
18
+ }
19
+ const parsed = Number(value);
20
+ if (!Number.isSafeInteger(parsed)) {
21
+ return { value: null, error: t(lang, 'securityPatternScan.error.invalidPositiveInteger', { option, value }) };
22
+ }
23
+ return { value: parsed };
24
+ }
25
+ export function getRepoSecurityPatternScanHelp(lang = 'en') {
26
+ return renderHelp({
27
+ usage: 'mf script-pack run repo/security-pattern-scan scan [path...] [options]',
28
+ summary: t(lang, 'securityPatternScan.help.summary'),
29
+ options: [
30
+ { label: '--max-files <count>', description: t(lang, 'securityPatternScan.help.option.maxFiles') },
31
+ { label: '--max-file-bytes <bytes>', description: t(lang, 'securityPatternScan.help.option.maxFileBytes') },
32
+ { label: '--max-findings <count>', description: t(lang, 'securityPatternScan.help.option.maxFindings') },
33
+ { label: '--json', description: t(lang, 'cli.option.json') },
34
+ { label: '-h, --help', description: t(lang, 'cli.option.help') },
35
+ ],
36
+ examples: [
37
+ 'mf script-pack run repo/security-pattern-scan scan --json',
38
+ 'mf script-pack run repo/security-pattern-scan scan src .github/workflows --json',
39
+ 'mf script-pack run repo/security-pattern-scan scan src/server.ts --max-findings 50 --json',
40
+ ],
41
+ exitCodes: [
42
+ { label: '0', description: t(lang, 'securityPatternScan.help.exit.ok') },
43
+ { label: '1', description: t(lang, 'securityPatternScan.help.exit.fail') },
44
+ ],
45
+ }, lang);
46
+ }
47
+ function parseSecurityPatternScanOptions(args, lang) {
48
+ const [action, ...rest] = args;
49
+ const parsed = parseCliOptions(rest, SECURITY_PATTERN_SCAN_OPTIONS, { allowPositionals: true });
50
+ const json = hasParsedCliOption(parsed, '--json');
51
+ const maxFiles = parsePositiveInteger(getParsedCliStringOption(parsed, '--max-files'), '--max-files', lang);
52
+ const maxFileBytes = parsePositiveInteger(getParsedCliStringOption(parsed, '--max-file-bytes'), '--max-file-bytes', lang);
53
+ const maxFindings = parsePositiveInteger(getParsedCliStringOption(parsed, '--max-findings'), '--max-findings', lang);
54
+ if (action !== 'scan') {
55
+ return {
56
+ action: 'scan',
57
+ json,
58
+ paths: parsed.positionals,
59
+ maxFiles: maxFiles.value,
60
+ maxFileBytes: maxFileBytes.value,
61
+ maxFindings: maxFindings.value,
62
+ error: action
63
+ ? t(lang, 'securityPatternScan.error.unknownAction', { action })
64
+ : t(lang, 'securityPatternScan.error.missingAction'),
65
+ };
66
+ }
67
+ if (parsed.error) {
68
+ return {
69
+ action,
70
+ json,
71
+ paths: parsed.positionals,
72
+ maxFiles: maxFiles.value,
73
+ maxFileBytes: maxFileBytes.value,
74
+ maxFindings: maxFindings.value,
75
+ error: formatCliOptionParseError(parsed.error, lang),
76
+ };
77
+ }
78
+ for (const candidate of [maxFiles, maxFileBytes, maxFindings]) {
79
+ if (candidate.error) {
80
+ return {
81
+ action,
82
+ json,
83
+ paths: parsed.positionals,
84
+ maxFiles: maxFiles.value,
85
+ maxFileBytes: maxFileBytes.value,
86
+ maxFindings: maxFindings.value,
87
+ error: candidate.error,
88
+ };
89
+ }
90
+ }
91
+ return {
92
+ action,
93
+ json,
94
+ paths: parsed.positionals,
95
+ maxFiles: maxFiles.value,
96
+ maxFileBytes: maxFileBytes.value,
97
+ maxFindings: maxFindings.value,
98
+ };
99
+ }
100
+ function renderSecurityPatternScanSummary(report, lang) {
101
+ const lines = [
102
+ t(lang, 'securityPatternScan.title'),
103
+ `${t(lang, 'scriptPack.label.script')}: ${SECURITY_PATTERN_SCAN_SCRIPT_REF}`,
104
+ `${t(lang, 'label.status')}: ${report.status}`,
105
+ `${t(lang, 'securityPatternScan.label.files')}: ${report.summary.file_count}`,
106
+ `${t(lang, 'securityPatternScan.label.findings')}: ${report.summary.finding_count}`,
107
+ `${t(lang, 'securityPatternScan.label.categories')}: ${report.summary.category_count}`,
108
+ `${t(lang, 'securityPatternScan.label.highOrCritical')}: ${report.summary.high_or_critical_count}`,
109
+ `${t(lang, 'securityPatternScan.label.truncated')}: ${report.truncated ? t(lang, 'value.yes') : t(lang, 'value.no')}`,
110
+ ];
111
+ if (report.findings.length > 0) {
112
+ lines.push(t(lang, 'securityPatternScan.label.findings'));
113
+ for (const finding of report.findings.slice(0, 40)) {
114
+ const line = finding.line ? `:${finding.line}` : '';
115
+ const detector = finding.detector ? ` ${finding.detector}` : '';
116
+ const focus = finding.review_focus ? ` ${t(lang, 'securityPatternScan.label.reviewFocus')}: ${finding.review_focus}` : '';
117
+ lines.push(`- ${finding.path}${line}: ${finding.code}${detector} (${finding.message})${focus}`);
118
+ }
119
+ }
120
+ if (report.issues.length > 0) {
121
+ lines.push(t(lang, 'securityPatternScan.label.issues'), ...report.issues.map((issue) => `- ${issue}`));
122
+ }
123
+ if (report.findings.length === 0 && report.issues.length === 0) {
124
+ lines.push(t(lang, 'securityPatternScan.clean'));
125
+ }
126
+ return lines.join('\n');
127
+ }
128
+ export function runRepoSecurityPatternScanScript(args, reporter, lang = 'en') {
129
+ if (hasCliOptionToken(args, '--help', ['-h'])) {
130
+ reporter.stdout(getRepoSecurityPatternScanHelp(lang));
131
+ return 0;
132
+ }
133
+ const options = parseSecurityPatternScanOptions(args, lang);
134
+ if (options.error) {
135
+ printUsageError(reporter, options.error, 'mf script-pack run repo/security-pattern-scan --help', getRepoSecurityPatternScanHelp(lang), lang);
136
+ return 1;
137
+ }
138
+ const report = inspectSecurityPatternScan(resolveMustflowRoot(), {
139
+ paths: options.paths,
140
+ maxFiles: options.maxFiles ?? undefined,
141
+ maxFileBytes: options.maxFileBytes ?? undefined,
142
+ maxFindings: options.maxFindings ?? undefined,
143
+ });
144
+ if (options.json) {
145
+ reporter.stdout(JSON.stringify(report, null, 2));
146
+ return report.ok ? 0 : 1;
147
+ }
148
+ reporter.stdout(renderSecurityPatternScanSummary(report, lang));
149
+ return report.ok ? 0 : 1;
150
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readString, readStringArray } from './config-loading.js';
4
4
  export const COMMAND_ENV_POLICIES = new Set(['inherit', 'minimal', 'allowlist']);
@@ -31,12 +31,30 @@ export const DEFAULT_PROJECT_LOCAL_BIN_BARE_EXECUTABLE_ALLOWLIST = new Set(['mf'
31
31
  function getPathEnvKey(env) {
32
32
  return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH';
33
33
  }
34
- function sameResolvedPath(left, right) {
35
- const resolvedLeft = path.resolve(left);
36
- const resolvedRight = path.resolve(right);
37
- return process.platform === 'win32'
38
- ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
39
- : resolvedLeft === resolvedRight;
34
+ function normalizePathForComparison(value) {
35
+ const resolved = path.resolve(value);
36
+ if (!existsSync(resolved)) {
37
+ return resolved;
38
+ }
39
+ try {
40
+ return realpathSync.native(resolved);
41
+ }
42
+ catch {
43
+ return resolved;
44
+ }
45
+ }
46
+ function pathsEqual(left, right) {
47
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
48
+ }
49
+ function pathEntryCandidates(entry, projectRoot) {
50
+ if (path.isAbsolute(entry)) {
51
+ return [entry];
52
+ }
53
+ return [path.resolve(projectRoot, entry), path.resolve(entry)];
54
+ }
55
+ function sameResolvedPath(left, right, projectRoot) {
56
+ const resolvedRight = normalizePathForComparison(right);
57
+ return pathEntryCandidates(left, projectRoot).some((candidate) => pathsEqual(normalizePathForComparison(candidate), resolvedRight));
40
58
  }
41
59
  function uniqueEnvNames(values) {
42
60
  return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))].sort((left, right) => left.localeCompare(right));
@@ -112,7 +130,7 @@ function removeProjectLocalBinFromPath(env, projectRoot) {
112
130
  ...env,
113
131
  [pathKey]: currentPath
114
132
  .split(path.delimiter)
115
- .filter((entry) => entry.length > 0 && !sameResolvedPath(entry, localBinPath))
133
+ .filter((entry) => entry.length > 0 && !sameResolvedPath(entry, localBinPath, projectRoot))
116
134
  .join(path.delimiter),
117
135
  };
118
136
  }
@@ -495,6 +495,15 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
495
495
  ],
496
496
  expectedExitCodes: [0, 1],
497
497
  },
498
+ {
499
+ id: 'repo-deploy-surface-report',
500
+ schemaFile: 'repo-deploy-surface-report.schema.json',
501
+ producer: 'mf script-pack run repo/deploy-surface inspect --json',
502
+ packaged: true,
503
+ documented: true,
504
+ installedCommand: ['mf', 'script-pack', 'run', 'repo/deploy-surface', 'inspect', '--json'],
505
+ expectedExitCodes: [0, 1],
506
+ },
498
507
  {
499
508
  id: 'config-chain-report',
500
509
  schemaFile: 'config-chain-report.schema.json',
@@ -530,6 +539,15 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
530
539
  installedCommand: ['mf', 'script-pack', 'run', 'repo/secret-risk-scan', 'scan', 'AGENTS.md', '--json'],
531
540
  expectedExitCodes: [0, 1],
532
541
  },
542
+ {
543
+ id: 'security-pattern-scan-report',
544
+ schemaFile: 'security-pattern-scan-report.schema.json',
545
+ producer: 'mf script-pack run repo/security-pattern-scan scan [path...] --json',
546
+ packaged: true,
547
+ documented: true,
548
+ installedCommand: ['mf', 'script-pack', 'run', 'repo/security-pattern-scan', 'scan', 'AGENTS.md', '--json'],
549
+ expectedExitCodes: [0, 1],
550
+ },
533
551
  {
534
552
  id: 'related-files-report',
535
553
  schemaFile: 'related-files-report.schema.json',
@@ -0,0 +1,428 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ export const REPO_DEPLOY_SURFACE_PACK_ID = 'repo';
5
+ export const REPO_DEPLOY_SURFACE_SCRIPT_ID = 'deploy-surface';
6
+ export const REPO_DEPLOY_SURFACE_SCRIPT_REF = `${REPO_DEPLOY_SURFACE_PACK_ID}/${REPO_DEPLOY_SURFACE_SCRIPT_ID}`;
7
+ const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
8
+ const WORKFLOW_DIR = '.github/workflows';
9
+ const WORKFLOW_EXTENSIONS = new Set(['.yml', '.yaml']);
10
+ const CONFIG_SURFACES = [
11
+ {
12
+ path: 'wrangler.toml',
13
+ type: 'cloudflare',
14
+ match: 'wrangler.toml',
15
+ requiredVerification: ['Cloudflare deploy or preview verification'],
16
+ manualGates: ['Cloudflare account, project, and secret configuration'],
17
+ },
18
+ {
19
+ path: 'wrangler.json',
20
+ type: 'cloudflare',
21
+ match: 'wrangler.json',
22
+ requiredVerification: ['Cloudflare deploy or preview verification'],
23
+ manualGates: ['Cloudflare account, project, and secret configuration'],
24
+ },
25
+ {
26
+ path: 'wrangler.jsonc',
27
+ type: 'cloudflare',
28
+ match: 'wrangler.jsonc',
29
+ requiredVerification: ['Cloudflare deploy or preview verification'],
30
+ manualGates: ['Cloudflare account, project, and secret configuration'],
31
+ },
32
+ {
33
+ path: 'vercel.json',
34
+ type: 'vercel',
35
+ match: 'vercel.json',
36
+ requiredVerification: ['Vercel preview or production deployment verification'],
37
+ manualGates: ['Vercel project and environment configuration'],
38
+ },
39
+ {
40
+ path: 'netlify.toml',
41
+ type: 'netlify',
42
+ match: 'netlify.toml',
43
+ requiredVerification: ['Netlify deploy preview or production deployment verification'],
44
+ manualGates: ['Netlify site and environment configuration'],
45
+ },
46
+ {
47
+ path: 'Dockerfile',
48
+ type: 'container',
49
+ match: 'Dockerfile',
50
+ requiredVerification: ['Container build and image smoke verification'],
51
+ manualGates: ['Container registry credentials and deployment target'],
52
+ },
53
+ {
54
+ path: 'docker-compose.yml',
55
+ type: 'container',
56
+ match: 'docker-compose.yml',
57
+ requiredVerification: ['Compose configuration and container smoke verification'],
58
+ manualGates: ['Runtime host, network, and volume configuration'],
59
+ },
60
+ {
61
+ path: 'docker-compose.yaml',
62
+ type: 'container',
63
+ match: 'docker-compose.yaml',
64
+ requiredVerification: ['Compose configuration and container smoke verification'],
65
+ manualGates: ['Runtime host, network, and volume configuration'],
66
+ },
67
+ {
68
+ path: 'compose.yml',
69
+ type: 'container',
70
+ match: 'compose.yml',
71
+ requiredVerification: ['Compose configuration and container smoke verification'],
72
+ manualGates: ['Runtime host, network, and volume configuration'],
73
+ },
74
+ {
75
+ path: 'compose.yaml',
76
+ type: 'container',
77
+ match: 'compose.yaml',
78
+ requiredVerification: ['Compose configuration and container smoke verification'],
79
+ manualGates: ['Runtime host, network, and volume configuration'],
80
+ },
81
+ ];
82
+ function sha256(value) {
83
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
84
+ }
85
+ function normalizeRelativePath(value) {
86
+ return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '');
87
+ }
88
+ function lineForOffset(content, offset) {
89
+ let line = 1;
90
+ for (let index = 0; index < offset; index += 1) {
91
+ if (content.charCodeAt(index) === 10) {
92
+ line += 1;
93
+ }
94
+ }
95
+ return line;
96
+ }
97
+ function firstMatchLine(content, pattern) {
98
+ const match = pattern.exec(content);
99
+ if (!match || match.index < 0) {
100
+ return null;
101
+ }
102
+ return {
103
+ line: lineForOffset(content, match.index),
104
+ match: match[0].trim(),
105
+ };
106
+ }
107
+ function uniqueStrings(values) {
108
+ return [...new Set([...values].filter((value) => value.trim().length > 0))].sort((left, right) => left.localeCompare(right));
109
+ }
110
+ function safeReadText(root, relativePath, maxFileBytes, issues) {
111
+ const absolute = path.join(root, ...normalizeRelativePath(relativePath).split('/'));
112
+ try {
113
+ const stats = statSync(absolute);
114
+ if (!stats.isFile()) {
115
+ return null;
116
+ }
117
+ if (stats.size > maxFileBytes) {
118
+ issues.push(`${relativePath} exceeds max_file_bytes (${stats.size} > ${maxFileBytes}).`);
119
+ return null;
120
+ }
121
+ return readFileSync(absolute, 'utf8');
122
+ }
123
+ catch (error) {
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ issues.push(`Could not read ${relativePath}: ${message}`);
126
+ return null;
127
+ }
128
+ }
129
+ function listWorkflowFiles(root, issues) {
130
+ const workflowsPath = path.join(root, ...WORKFLOW_DIR.split('/'));
131
+ if (!existsSync(workflowsPath)) {
132
+ return [];
133
+ }
134
+ try {
135
+ return readdirSync(workflowsPath, { withFileTypes: true })
136
+ .filter((entry) => entry.isFile() && WORKFLOW_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
137
+ .map((entry) => `${WORKFLOW_DIR}/${entry.name}`)
138
+ .sort((left, right) => left.localeCompare(right));
139
+ }
140
+ catch (error) {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ issues.push(`Could not list ${WORKFLOW_DIR}: ${message}`);
143
+ return [];
144
+ }
145
+ }
146
+ function workflowTrigger(content) {
147
+ const tagPush = firstMatchLine(content, /\btags(?:-ignore)?:\s*(?:\[[^\]]+\]|.+)/u);
148
+ if (tagPush) {
149
+ return `tag push (${tagPush.match})`;
150
+ }
151
+ const release = firstMatchLine(content, /^\s*release:\s*$/mu);
152
+ if (release) {
153
+ return 'GitHub release event';
154
+ }
155
+ const workflowDispatch = firstMatchLine(content, /^\s*workflow_dispatch:\s*$/mu);
156
+ if (workflowDispatch) {
157
+ return 'manual workflow_dispatch';
158
+ }
159
+ const push = firstMatchLine(content, /^\s*push:\s*$/mu);
160
+ if (push) {
161
+ return 'push';
162
+ }
163
+ return null;
164
+ }
165
+ function workflowManualGates(content, surfaceType) {
166
+ const gates = [];
167
+ if (/^\s*environment:\s*/mu.test(content)) {
168
+ gates.push('GitHub environment protection may gate this workflow.');
169
+ }
170
+ if (/^\s*workflow_dispatch:\s*$/mu.test(content)) {
171
+ gates.push('Manual workflow_dispatch trigger is available.');
172
+ }
173
+ if (surfaceType === 'npm_publish') {
174
+ gates.push('npm trusted publishing, token, or maintainer permission is required.');
175
+ }
176
+ if (surfaceType === 'github_pages') {
177
+ gates.push('GitHub Pages project settings and permissions may gate publication.');
178
+ }
179
+ return uniqueStrings(gates);
180
+ }
181
+ function verificationForWorkflow(surfaceType) {
182
+ if (surfaceType === 'npm_publish') {
183
+ return ['release_npm_version_available before tag or publish', 'release_npm_published_verify after publish'];
184
+ }
185
+ if (surfaceType === 'github_pages') {
186
+ return ['docs_validate before deploy', 'published Pages URL smoke check after deploy'];
187
+ }
188
+ if (surfaceType === 'github_release') {
189
+ return ['tag workflow run success', 'GitHub Release asset and metadata verification'];
190
+ }
191
+ if (surfaceType === 'container') {
192
+ return ['container image build', 'registry push and runtime smoke verification'];
193
+ }
194
+ if (surfaceType === 'cloudflare') {
195
+ return ['Cloudflare deploy or preview verification'];
196
+ }
197
+ if (surfaceType === 'vercel') {
198
+ return ['Vercel deploy verification'];
199
+ }
200
+ if (surfaceType === 'netlify') {
201
+ return ['Netlify deploy verification'];
202
+ }
203
+ return ['deployment workflow run success', 'post-deploy smoke verification'];
204
+ }
205
+ function addWorkflowSurface(seeds, content, relativePath, surfaceType, pattern, matchFallback) {
206
+ const match = firstMatchLine(content, pattern);
207
+ if (!match) {
208
+ return;
209
+ }
210
+ seeds.push({
211
+ kind: 'github_actions_workflow',
212
+ surfaceType,
213
+ path: relativePath,
214
+ line: match.line,
215
+ trigger: workflowTrigger(content),
216
+ confidence: surfaceType === 'generic_deploy' ? 'medium' : 'high',
217
+ match: match.match || matchFallback,
218
+ requiredVerification: verificationForWorkflow(surfaceType),
219
+ manualGates: workflowManualGates(content, surfaceType),
220
+ });
221
+ }
222
+ function scanWorkflowFile(root, relativePath, maxFileBytes, seeds, issues) {
223
+ const content = safeReadText(root, relativePath, maxFileBytes, issues);
224
+ if (content === null) {
225
+ return;
226
+ }
227
+ addWorkflowSurface(seeds, content, relativePath, 'npm_publish', /\b(?:npm|bun|pnpm)\s+publish\b|yarn\s+npm\s+publish\b|JS-DevTools\/npm-publish/u, 'npm publish');
228
+ addWorkflowSurface(seeds, content, relativePath, 'github_pages', /actions\/deploy-pages|pages:\s*write|github-pages|pages\.github\.io/u, 'GitHub Pages deploy');
229
+ addWorkflowSurface(seeds, content, relativePath, 'github_release', /softprops\/action-gh-release|gh\s+release\b|actions\/create-release|create-release/u, 'GitHub release');
230
+ addWorkflowSurface(seeds, content, relativePath, 'container', /docker\/build-push-action|\bdocker\s+(?:build|push)\b|ghcr\.io|container_registry/u, 'container build or push');
231
+ addWorkflowSurface(seeds, content, relativePath, 'cloudflare', /cloudflare\/wrangler-action|\bwrangler\s+deploy\b/u, 'Cloudflare deploy');
232
+ addWorkflowSurface(seeds, content, relativePath, 'vercel', /\bvercel\s+(?:deploy|--prod)\b/u, 'Vercel deploy');
233
+ addWorkflowSurface(seeds, content, relativePath, 'netlify', /\bnetlify\s+deploy\b|actions-netlify/u, 'Netlify deploy');
234
+ addWorkflowSurface(seeds, content, relativePath, 'generic_deploy', /\bdeploy(?:ment)?\b/u, 'deploy');
235
+ }
236
+ function scanPackageJson(root, maxFileBytes, seeds, issues) {
237
+ const relativePath = 'package.json';
238
+ if (!existsSync(path.join(root, relativePath))) {
239
+ return;
240
+ }
241
+ const content = safeReadText(root, relativePath, maxFileBytes, issues);
242
+ if (content === null) {
243
+ return;
244
+ }
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(content);
248
+ }
249
+ catch (error) {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ issues.push(`Could not parse ${relativePath}: ${message}`);
252
+ return;
253
+ }
254
+ if (!parsed || typeof parsed !== 'object') {
255
+ issues.push(`${relativePath} must contain a JSON object.`);
256
+ return;
257
+ }
258
+ const record = parsed;
259
+ const scripts = record.scripts;
260
+ if (scripts && typeof scripts === 'object' && !Array.isArray(scripts)) {
261
+ for (const [scriptName, scriptValue] of Object.entries(scripts)) {
262
+ if (typeof scriptValue !== 'string') {
263
+ continue;
264
+ }
265
+ const lowerName = scriptName.toLowerCase();
266
+ const lowerValue = scriptValue.toLowerCase();
267
+ const releaseLike = /(?:^|:)(?:deploy|release|publish|prepublishonly|postpublish)$/u.test(lowerName);
268
+ const commandLike = /\b(?:npm|bun|pnpm)\s+publish\b|yarn\s+npm\s+publish\b|\b(?:wrangler|vercel|netlify)\s+deploy\b|\bgh\s+release\b/u.test(lowerValue);
269
+ if (!releaseLike && !commandLike) {
270
+ continue;
271
+ }
272
+ const surfaceType = lowerValue.includes('wrangler')
273
+ ? 'cloudflare'
274
+ : lowerValue.includes('vercel')
275
+ ? 'vercel'
276
+ : lowerValue.includes('netlify')
277
+ ? 'netlify'
278
+ : lowerName.includes('publish') || lowerValue.includes('publish')
279
+ ? 'npm_publish'
280
+ : lowerName.includes('release') || lowerValue.includes('gh release')
281
+ ? 'package_release'
282
+ : 'generic_deploy';
283
+ const line = firstMatchLine(content, new RegExp(`"${scriptName.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}"\\s*:`, 'u'));
284
+ seeds.push({
285
+ kind: 'package_script',
286
+ surfaceType,
287
+ path: relativePath,
288
+ line: line?.line ?? null,
289
+ trigger: `package script: ${scriptName}`,
290
+ confidence: commandLike ? 'high' : 'medium',
291
+ match: `${scriptName}: ${scriptValue}`,
292
+ requiredVerification: verificationForWorkflow(surfaceType),
293
+ manualGates: surfaceType === 'npm_publish'
294
+ ? ['npm trusted publishing, token, or maintainer permission is required.']
295
+ : [],
296
+ });
297
+ }
298
+ }
299
+ if (typeof record.name === 'string' &&
300
+ typeof record.version === 'string' &&
301
+ record.private !== true &&
302
+ (record.publishConfig || record.bin || record.exports)) {
303
+ const line = firstMatchLine(content, /"publishConfig"|"bin"|"exports"/u);
304
+ seeds.push({
305
+ kind: 'package_metadata',
306
+ surfaceType: 'npm_publish',
307
+ path: relativePath,
308
+ line: line?.line ?? null,
309
+ trigger: 'package metadata',
310
+ confidence: record.publishConfig ? 'high' : 'medium',
311
+ match: line?.match ?? 'publishable package metadata',
312
+ requiredVerification: ['release_npm_version_available before publish', 'release_npm_published_verify after publish'],
313
+ manualGates: ['npm trusted publishing, token, or maintainer permission is required.'],
314
+ });
315
+ }
316
+ }
317
+ function scanConfigSurfaces(root, seeds) {
318
+ for (const candidate of CONFIG_SURFACES) {
319
+ if (!existsSync(path.join(root, ...candidate.path.split('/')))) {
320
+ continue;
321
+ }
322
+ seeds.push({
323
+ kind: 'deploy_config',
324
+ surfaceType: candidate.type,
325
+ path: candidate.path,
326
+ line: null,
327
+ trigger: null,
328
+ confidence: 'medium',
329
+ match: candidate.match,
330
+ requiredVerification: candidate.requiredVerification,
331
+ manualGates: candidate.manualGates,
332
+ });
333
+ }
334
+ }
335
+ function seedId(seed, index) {
336
+ return `${seed.kind}:${seed.surfaceType}:${seed.path}:${seed.line ?? 0}:${index}`;
337
+ }
338
+ function toSurface(seed, index) {
339
+ return {
340
+ id: seedId(seed, index),
341
+ kind: seed.kind,
342
+ surface_type: seed.surfaceType,
343
+ path: seed.path,
344
+ line: seed.line,
345
+ trigger: seed.trigger,
346
+ confidence: seed.confidence,
347
+ evidence: {
348
+ path: seed.path,
349
+ line: seed.line,
350
+ match: seed.match,
351
+ },
352
+ required_verification: uniqueStrings(seed.requiredVerification),
353
+ manual_gates: uniqueStrings(seed.manualGates),
354
+ };
355
+ }
356
+ function createFindings(surfaces) {
357
+ return surfaces.map((surface) => ({
358
+ code: 'deploy_surface_detected',
359
+ severity: surface.confidence === 'high' ? 'medium' : 'low',
360
+ path: surface.path,
361
+ message: `Detected ${surface.surface_type} deploy surface in ${surface.path}.`,
362
+ json_pointer: null,
363
+ metric: null,
364
+ actual: null,
365
+ expected: null,
366
+ }));
367
+ }
368
+ function createSummary(surfaces) {
369
+ return {
370
+ has_deploy_surface: surfaces.length > 0,
371
+ surface_count: surfaces.length,
372
+ workflow_count: surfaces.filter((surface) => surface.kind === 'github_actions_workflow').length,
373
+ package_script_count: surfaces.filter((surface) => surface.kind === 'package_script').length,
374
+ config_file_count: surfaces.filter((surface) => surface.kind === 'deploy_config').length,
375
+ package_metadata_count: surfaces.filter((surface) => surface.kind === 'package_metadata').length,
376
+ manual_gate_count: uniqueStrings(surfaces.flatMap((surface) => surface.manual_gates)).length,
377
+ required_verification_count: uniqueStrings(surfaces.flatMap((surface) => surface.required_verification)).length,
378
+ };
379
+ }
380
+ function createInputHash(reportInput) {
381
+ return sha256(JSON.stringify(reportInput));
382
+ }
383
+ export function inspectRepoDeploySurface(projectRoot) {
384
+ const root = path.resolve(projectRoot);
385
+ const issues = [];
386
+ const maxFileBytes = DEFAULT_MAX_FILE_BYTES;
387
+ const scannedPaths = new Set();
388
+ const seeds = [];
389
+ for (const workflowPath of listWorkflowFiles(root, issues)) {
390
+ scannedPaths.add(workflowPath);
391
+ scanWorkflowFile(root, workflowPath, maxFileBytes, seeds, issues);
392
+ }
393
+ scannedPaths.add('package.json');
394
+ scanPackageJson(root, maxFileBytes, seeds, issues);
395
+ for (const candidate of CONFIG_SURFACES) {
396
+ scannedPaths.add(candidate.path);
397
+ }
398
+ scanConfigSurfaces(root, seeds);
399
+ const surfaces = seeds.map(toSurface).sort((left, right) => left.path.localeCompare(right.path) || left.id.localeCompare(right.id));
400
+ const summary = createSummary(surfaces);
401
+ const requiredVerification = uniqueStrings(surfaces.flatMap((surface) => surface.required_verification));
402
+ const manualGates = uniqueStrings(surfaces.flatMap((surface) => surface.manual_gates));
403
+ const findings = createFindings(surfaces);
404
+ const status = issues.length > 0 ? 'error' : 'passed';
405
+ return {
406
+ schema_version: '1',
407
+ command: 'script-pack',
408
+ pack_id: REPO_DEPLOY_SURFACE_PACK_ID,
409
+ script_id: REPO_DEPLOY_SURFACE_SCRIPT_ID,
410
+ script_ref: REPO_DEPLOY_SURFACE_SCRIPT_REF,
411
+ action: 'inspect',
412
+ status,
413
+ ok: status === 'passed',
414
+ mustflow_root: root,
415
+ input: {
416
+ scanned_paths: uniqueStrings(scannedPaths),
417
+ max_file_bytes: maxFileBytes,
418
+ },
419
+ input_hash: createInputHash({ summary, surfaces, requiredVerification, manualGates, findings, issues }),
420
+ has_deploy_surface: summary.has_deploy_surface,
421
+ summary,
422
+ surfaces,
423
+ required_verification: requiredVerification,
424
+ manual_gates: manualGates,
425
+ findings,
426
+ issues,
427
+ };
428
+ }