mustflow 2.103.3 → 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.
- package/dist/cli/commands/run.js +11 -0
- package/dist/cli/commands/script-pack.js +2 -0
- package/dist/cli/i18n/en.js +35 -0
- package/dist/cli/i18n/es.js +35 -0
- package/dist/cli/i18n/fr.js +35 -0
- package/dist/cli/i18n/hi.js +35 -0
- package/dist/cli/i18n/ko.js +35 -0
- package/dist/cli/i18n/zh.js +35 -0
- package/dist/cli/lib/external-skill-import.js +78 -14
- package/dist/cli/lib/local-index/sql.js +9 -1
- package/dist/cli/lib/run-plan.js +37 -0
- package/dist/cli/lib/script-pack-registry.js +57 -0
- package/dist/cli/script-packs/repo-deploy-surface.js +98 -0
- package/dist/cli/script-packs/repo-security-pattern-scan.js +150 -0
- package/dist/core/change-impact.js +16 -0
- package/dist/core/code-outline.js +3 -13
- package/dist/core/command-env.js +26 -8
- package/dist/core/config-chain.js +3 -13
- package/dist/core/dependency-graph.js +3 -13
- package/dist/core/docs-link-integrity.js +23 -4
- package/dist/core/env-contract.js +3 -13
- package/dist/core/export-diff.js +3 -3
- package/dist/core/ignored-directories.js +40 -0
- package/dist/core/public-json-contracts.js +18 -0
- package/dist/core/reference-drift.js +4 -2
- package/dist/core/related-files.js +3 -13
- package/dist/core/repo-deploy-surface.js +428 -0
- package/dist/core/repo-merge-conflict-scan.js +3 -9
- package/dist/core/route-outline.js +3 -13
- package/dist/core/script-pack-suggestions.js +52 -14
- package/dist/core/secret-risk-scan.js +3 -13
- package/dist/core/security-pattern-scan.js +518 -0
- package/dist/core/skill-route-resolution.js +21 -1
- package/package.json +2 -2
- package/schemas/README.md +7 -0
- package/schemas/link-integrity-report.schema.json +1 -0
- package/schemas/reference-drift-report.schema.json +1 -0
- package/schemas/repo-deploy-surface-report.schema.json +190 -0
- package/schemas/security-pattern-scan-report.schema.json +196 -0
- package/templates/default/i18n.toml +20 -8
- package/templates/default/locales/en/.mustflow/skills/ai-generated-code-hardening/SKILL.md +30 -7
- package/templates/default/locales/en/.mustflow/skills/api-contract-change/SKILL.md +18 -9
- package/templates/default/locales/en/.mustflow/skills/api-request-performance-review/SKILL.md +12 -6
- package/templates/default/locales/en/.mustflow/skills/completion-evidence-gate/SKILL.md +20 -9
- package/templates/default/locales/en/.mustflow/skills/hot-path-performance-review/SKILL.md +20 -15
- package/templates/default/locales/en/.mustflow/skills/next-action-menu/SKILL.md +22 -7
- package/templates/default/locales/en/.mustflow/skills/quadratic-scan-review/SKILL.md +21 -19
- package/templates/default/locales/en/.mustflow/skills/react-code-change/SKILL.md +54 -8
- package/templates/default/locales/en/.mustflow/skills/vertical-slice-tdd/SKILL.md +22 -8
- package/templates/default/manifest.toml +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const DEFAULT_IGNORED_DIRECTORIES = [
|
|
2
|
+
'.git',
|
|
3
|
+
'.mustflow/cache',
|
|
4
|
+
'.mustflow/state',
|
|
5
|
+
'node_modules',
|
|
6
|
+
'dist',
|
|
7
|
+
'build',
|
|
8
|
+
'coverage',
|
|
9
|
+
'.next',
|
|
10
|
+
'.turbo',
|
|
11
|
+
'.astro',
|
|
12
|
+
];
|
|
13
|
+
function normalizeDirectoryPath(value) {
|
|
14
|
+
return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '').replace(/\/+$/u, '') || '.';
|
|
15
|
+
}
|
|
16
|
+
function containsSegmentSequence(segments, sequence) {
|
|
17
|
+
if (sequence.length === 0 || sequence.length > segments.length) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
for (let index = 0; index <= segments.length - sequence.length; index += 1) {
|
|
21
|
+
if (sequence.every((segment, offset) => segments[index + offset] === segment)) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
export function isIgnoredDirectoryPath(relativePath, ignoredDirectories = DEFAULT_IGNORED_DIRECTORIES) {
|
|
28
|
+
const normalized = normalizeDirectoryPath(relativePath);
|
|
29
|
+
if (normalized === '.') {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const segments = normalized.split('/').filter((segment) => segment.length > 0);
|
|
33
|
+
return ignoredDirectories.some((entry) => {
|
|
34
|
+
const ignoredSegments = normalizeDirectoryPath(entry).split('/').filter((segment) => segment.length > 0);
|
|
35
|
+
if (ignoredSegments.length === 1) {
|
|
36
|
+
return segments.includes(ignoredSegments[0] ?? '');
|
|
37
|
+
}
|
|
38
|
+
return containsSegmentSequence(segments, ignoredSegments);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -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',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { DEFAULT_IGNORED_DIRECTORIES, isIgnoredDirectoryPath } from './ignored-directories.js';
|
|
4
5
|
import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
5
6
|
export const REFERENCE_DRIFT_PACK_ID = 'docs';
|
|
6
7
|
export const REFERENCE_DRIFT_SCRIPT_ID = 'reference-drift';
|
|
@@ -16,7 +17,7 @@ const CHECKED_REFERENCE_KINDS = [
|
|
|
16
17
|
'schema_file',
|
|
17
18
|
'repo_path',
|
|
18
19
|
];
|
|
19
|
-
const IGNORED_DIRECTORIES =
|
|
20
|
+
const IGNORED_DIRECTORIES = DEFAULT_IGNORED_DIRECTORIES;
|
|
20
21
|
const ERROR_CODES = new Set([
|
|
21
22
|
'reference_drift_path_outside_root',
|
|
22
23
|
'reference_drift_unreadable_path',
|
|
@@ -64,7 +65,7 @@ function addCandidate(candidates, findings, issues, policy, candidate) {
|
|
|
64
65
|
}
|
|
65
66
|
function collectDocumentsFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
|
|
66
67
|
const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
|
|
67
|
-
if (
|
|
68
|
+
if (isIgnoredDirectoryPath(relativeDirectory, IGNORED_DIRECTORIES)) {
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
70
71
|
let entries;
|
|
@@ -318,6 +319,7 @@ export function checkReferenceDrift(projectRoot, options) {
|
|
|
318
319
|
max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
319
320
|
default_paths: [...DEFAULT_PATHS],
|
|
320
321
|
path_filters: [...PATH_FILTERS],
|
|
322
|
+
ignored_directories: [...IGNORED_DIRECTORIES],
|
|
321
323
|
checked_reference_kinds: [...CHECKED_REFERENCE_KINDS],
|
|
322
324
|
};
|
|
323
325
|
const commandNames = new Set(options.commandNames);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { DEFAULT_IGNORED_DIRECTORIES, isIgnoredDirectoryPath } from './ignored-directories.js';
|
|
4
5
|
import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
5
6
|
export const RELATED_FILES_PACK_ID = 'repo';
|
|
6
7
|
export const RELATED_FILES_SCRIPT_ID = 'related-files';
|
|
@@ -12,17 +13,7 @@ const MAX_ISSUES = 50;
|
|
|
12
13
|
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
|
|
13
14
|
const RESOLVE_EXTENSIONS = [...SOURCE_EXTENSIONS, '.json'];
|
|
14
15
|
const RELATED_EXTENSIONS = [...RESOLVE_EXTENSIONS, '.d.ts', '.md', '.mdx', '.css', '.scss', '.sass', '.less'];
|
|
15
|
-
const IGNORED_DIRECTORIES =
|
|
16
|
-
'.git',
|
|
17
|
-
'.mustflow/cache',
|
|
18
|
-
'.mustflow/state',
|
|
19
|
-
'node_modules',
|
|
20
|
-
'dist',
|
|
21
|
-
'build',
|
|
22
|
-
'coverage',
|
|
23
|
-
'.next',
|
|
24
|
-
'.turbo',
|
|
25
|
-
];
|
|
16
|
+
const IGNORED_DIRECTORIES = DEFAULT_IGNORED_DIRECTORIES;
|
|
26
17
|
const CONFIG_FILE_PATTERNS = [
|
|
27
18
|
/^package\.json$/u,
|
|
28
19
|
/^tsconfig(?:\.[^.]+)?\.json$/u,
|
|
@@ -71,8 +62,7 @@ function isSourceLanguage(language) {
|
|
|
71
62
|
return language !== 'json' && language !== 'other';
|
|
72
63
|
}
|
|
73
64
|
function isIgnoredDirectory(relativePath) {
|
|
74
|
-
|
|
75
|
-
return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
|
|
65
|
+
return isIgnoredDirectoryPath(normalizeRelativePath(relativePath), IGNORED_DIRECTORIES);
|
|
76
66
|
}
|
|
77
67
|
function makeFinding(code, severity, pathValue, message) {
|
|
78
68
|
return { code, severity, path: pathValue, message };
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { DEFAULT_IGNORED_DIRECTORIES, isIgnoredDirectoryPath } from './ignored-directories.js';
|
|
5
6
|
import { ensureInsideWithoutSymlinks, readUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
6
7
|
export const REPO_MERGE_CONFLICT_SCAN_PACK_ID = 'repo';
|
|
7
8
|
export const REPO_MERGE_CONFLICT_SCAN_SCRIPT_ID = 'merge-conflict-scan';
|
|
@@ -15,15 +16,9 @@ const MARKER_PATTERNS = [
|
|
|
15
16
|
{ prefix: '>>>>>>>', marker: 'end' },
|
|
16
17
|
];
|
|
17
18
|
const SKIPPED_DIRECTORY_NAMES = new Set([
|
|
18
|
-
|
|
19
|
-
'node_modules',
|
|
19
|
+
...DEFAULT_IGNORED_DIRECTORIES,
|
|
20
20
|
'vendor',
|
|
21
21
|
'third_party',
|
|
22
|
-
'dist',
|
|
23
|
-
'build',
|
|
24
|
-
'coverage',
|
|
25
|
-
'.mustflow/cache',
|
|
26
|
-
'.mustflow/state',
|
|
27
22
|
]);
|
|
28
23
|
function normalizeRelativePath(value) {
|
|
29
24
|
return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '').replace(/\/+$/u, '') || '.';
|
|
@@ -95,8 +90,7 @@ function collectGitChangedFiles(root, issues, findings) {
|
|
|
95
90
|
}
|
|
96
91
|
function shouldSkipDirectory(relativePath) {
|
|
97
92
|
const normalized = normalizeRelativePath(relativePath);
|
|
98
|
-
|
|
99
|
-
return SKIPPED_DIRECTORY_NAMES.has(normalized) || SKIPPED_DIRECTORY_NAMES.has(firstSegment);
|
|
93
|
+
return isIgnoredDirectoryPath(normalized, [...SKIPPED_DIRECTORY_NAMES]);
|
|
100
94
|
}
|
|
101
95
|
function isLikelyTextFile(relativePath) {
|
|
102
96
|
const extension = path.posix.extname(relativePath).toLowerCase();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { lstatSync, readdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { DEFAULT_IGNORED_DIRECTORIES, isIgnoredDirectoryPath } from './ignored-directories.js';
|
|
4
5
|
import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
5
6
|
export const CODE_ROUTE_OUTLINE_SCRIPT_ID = 'route-outline';
|
|
6
7
|
export const CODE_ROUTE_OUTLINE_SCRIPT_REF = `code/${CODE_ROUTE_OUTLINE_SCRIPT_ID}`;
|
|
@@ -39,17 +40,7 @@ const LIFECYCLE_METHODS = [
|
|
|
39
40
|
'onAfterHandle',
|
|
40
41
|
'onError',
|
|
41
42
|
];
|
|
42
|
-
const IGNORED_DIRECTORIES =
|
|
43
|
-
'.git',
|
|
44
|
-
'.mustflow/cache',
|
|
45
|
-
'.mustflow/state',
|
|
46
|
-
'node_modules',
|
|
47
|
-
'dist',
|
|
48
|
-
'build',
|
|
49
|
-
'coverage',
|
|
50
|
-
'.next',
|
|
51
|
-
'.turbo',
|
|
52
|
-
];
|
|
43
|
+
const IGNORED_DIRECTORIES = DEFAULT_IGNORED_DIRECTORIES;
|
|
53
44
|
const ERROR_CODES = new Set([
|
|
54
45
|
'code_route_outline_path_outside_root',
|
|
55
46
|
'code_route_outline_unreadable_path',
|
|
@@ -85,8 +76,7 @@ function languageForPath(filePath) {
|
|
|
85
76
|
}
|
|
86
77
|
}
|
|
87
78
|
function isIgnoredDirectory(relativePath) {
|
|
88
|
-
|
|
89
|
-
return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
|
|
79
|
+
return isIgnoredDirectoryPath(normalizeRelativePath(relativePath), IGNORED_DIRECTORIES);
|
|
90
80
|
}
|
|
91
81
|
function makeFinding(code, severity, pathValue, message) {
|
|
92
82
|
return { code, severity, path: pathValue, message };
|