mustflow 2.84.0 → 2.99.0

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.
Files changed (105) hide show
  1. package/README.md +11 -2
  2. package/dist/cli/commands/script-pack.js +14 -0
  3. package/dist/cli/i18n/en.js +262 -0
  4. package/dist/cli/i18n/es.js +262 -0
  5. package/dist/cli/i18n/fr.js +262 -0
  6. package/dist/cli/i18n/hi.js +262 -0
  7. package/dist/cli/i18n/ko.js +262 -0
  8. package/dist/cli/i18n/zh.js +262 -0
  9. package/dist/cli/lib/repo-map.js +27 -6
  10. package/dist/cli/lib/run-root-trust.js +15 -1
  11. package/dist/cli/lib/script-pack-registry.js +397 -0
  12. package/dist/cli/lib/validation/index.js +2 -2
  13. package/dist/cli/lib/validation/primitives.js +4 -1
  14. package/dist/cli/script-packs/code-change-impact.js +178 -0
  15. package/dist/cli/script-packs/code-dependency-graph.js +181 -0
  16. package/dist/cli/script-packs/code-import-cycle.js +193 -0
  17. package/dist/cli/script-packs/docs-link-integrity.js +145 -0
  18. package/dist/cli/script-packs/repo-approval-gate.js +100 -0
  19. package/dist/cli/script-packs/repo-env-contract.js +156 -0
  20. package/dist/cli/script-packs/repo-git-ignore-audit.js +119 -0
  21. package/dist/cli/script-packs/repo-manifest-lock-drift.js +122 -0
  22. package/dist/cli/script-packs/repo-merge-conflict-scan.js +123 -0
  23. package/dist/cli/script-packs/repo-secret-risk-scan.js +147 -0
  24. package/dist/cli/script-packs/repo-skill-route-audit.js +86 -0
  25. package/dist/cli/script-packs/repo-version-source.js +92 -0
  26. package/dist/cli/script-packs/test-performance-report.js +247 -0
  27. package/dist/cli/script-packs/test-regression-selector.js +167 -0
  28. package/dist/core/change-impact.js +355 -0
  29. package/dist/core/change-surface-classification.js +198 -0
  30. package/dist/core/change-verification.js +32 -5
  31. package/dist/core/config-loading.js +121 -4
  32. package/dist/core/dependency-graph.js +490 -0
  33. package/dist/core/docs-link-integrity.js +443 -0
  34. package/dist/core/env-contract.js +450 -0
  35. package/dist/core/import-cycle.js +152 -0
  36. package/dist/core/line-endings.js +26 -13
  37. package/dist/core/public-json-contracts.js +167 -0
  38. package/dist/core/repo-approval-gate.js +116 -0
  39. package/dist/core/repo-git-ignore-audit.js +302 -0
  40. package/dist/core/repo-manifest-lock-drift.js +321 -0
  41. package/dist/core/repo-merge-conflict-scan.js +335 -0
  42. package/dist/core/repo-version-source.js +82 -0
  43. package/dist/core/route-outline.js +57 -5
  44. package/dist/core/script-pack-suggestions.js +97 -1
  45. package/dist/core/secret-risk-scan.js +440 -0
  46. package/dist/core/skill-route-audit.js +354 -0
  47. package/dist/core/test-performance-report.js +697 -0
  48. package/dist/core/test-regression-selector.js +335 -0
  49. package/package.json +1 -1
  50. package/schemas/README.md +54 -0
  51. package/schemas/change-impact-report.schema.json +184 -0
  52. package/schemas/commands.schema.json +12 -0
  53. package/schemas/dependency-graph-report.schema.json +149 -0
  54. package/schemas/env-contract-report.schema.json +203 -0
  55. package/schemas/import-cycle-report.schema.json +157 -0
  56. package/schemas/link-integrity-report.schema.json +176 -0
  57. package/schemas/repo-approval-gate-report.schema.json +115 -0
  58. package/schemas/repo-git-ignore-audit-report.schema.json +201 -0
  59. package/schemas/repo-manifest-lock-drift-report.schema.json +202 -0
  60. package/schemas/repo-merge-conflict-scan-report.schema.json +169 -0
  61. package/schemas/repo-version-source-report.schema.json +127 -0
  62. package/schemas/secret-risk-scan-report.schema.json +152 -0
  63. package/schemas/skill-route-audit-report.schema.json +144 -0
  64. package/schemas/test-performance-report.schema.json +319 -0
  65. package/schemas/test-regression-selector-report.schema.json +187 -0
  66. package/templates/default/i18n.toml +80 -26
  67. package/templates/default/locales/en/.mustflow/skills/INDEX.md +51 -9
  68. package/templates/default/locales/en/.mustflow/skills/api-access-control-review/SKILL.md +48 -27
  69. package/templates/default/locales/en/.mustflow/skills/api-failure-triage/SKILL.md +270 -0
  70. package/templates/default/locales/en/.mustflow/skills/architecture-deepening-review/SKILL.md +28 -11
  71. package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +71 -27
  72. package/templates/default/locales/en/.mustflow/skills/auth-flow-triage/SKILL.md +192 -0
  73. package/templates/default/locales/en/.mustflow/skills/auth-permission-change/SKILL.md +59 -13
  74. package/templates/default/locales/en/.mustflow/skills/backend-log-evidence-review/SKILL.md +14 -5
  75. package/templates/default/locales/en/.mustflow/skills/cache-integrity-review/SKILL.md +30 -15
  76. package/templates/default/locales/en/.mustflow/skills/change-blast-radius-review/SKILL.md +45 -32
  77. package/templates/default/locales/en/.mustflow/skills/ci-pipeline-triage/SKILL.md +200 -0
  78. package/templates/default/locales/en/.mustflow/skills/clarifying-question-gate/SKILL.md +87 -13
  79. package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +23 -8
  80. package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +3 -1
  81. package/templates/default/locales/en/.mustflow/skills/docker-runtime-triage/SKILL.md +191 -0
  82. package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +48 -11
  83. package/templates/default/locales/en/.mustflow/skills/go-code-change/SKILL.md +18 -13
  84. package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +15 -13
  85. package/templates/default/locales/en/.mustflow/skills/line-ending-hygiene/SKILL.md +18 -10
  86. package/templates/default/locales/en/.mustflow/skills/llm-hallucination-control-review/SKILL.md +4 -1
  87. package/templates/default/locales/en/.mustflow/skills/motion-system-contract-review/SKILL.md +155 -0
  88. package/templates/default/locales/en/.mustflow/skills/next-action-menu/SKILL.md +177 -0
  89. package/templates/default/locales/en/.mustflow/skills/node-code-change/SKILL.md +16 -14
  90. package/templates/default/locales/en/.mustflow/skills/observability-debuggability-review/SKILL.md +15 -7
  91. package/templates/default/locales/en/.mustflow/skills/payment-integrity-review/SKILL.md +59 -35
  92. package/templates/default/locales/en/.mustflow/skills/powershell-code-change/SKILL.md +16 -6
  93. package/templates/default/locales/en/.mustflow/skills/prompt-contract-quality-review/SKILL.md +4 -1
  94. package/templates/default/locales/en/.mustflow/skills/python-code-change/SKILL.md +19 -10
  95. package/templates/default/locales/en/.mustflow/skills/rag-pipeline-triage/SKILL.md +206 -0
  96. package/templates/default/locales/en/.mustflow/skills/routes.toml +69 -9
  97. package/templates/default/locales/en/.mustflow/skills/rust-code-change/SKILL.md +10 -4
  98. package/templates/default/locales/en/.mustflow/skills/search-index-integrity-review/SKILL.md +181 -0
  99. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +3 -1
  100. package/templates/default/locales/en/.mustflow/skills/service-boundary-architecture/SKILL.md +37 -23
  101. package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +323 -0
  102. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +18 -10
  103. package/templates/default/locales/en/.mustflow/skills/vector-search-integrity-review/SKILL.md +209 -0
  104. package/templates/default/locales/en/.mustflow/skills/version-freshness-check/SKILL.md +16 -14
  105. package/templates/default/manifest.toml +71 -1
@@ -0,0 +1,321 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { isRecord } from './config-loading.js';
5
+ import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks, readUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
6
+ import { parseTomlText } from './toml.js';
7
+ export const REPO_MANIFEST_LOCK_DRIFT_PACK_ID = 'repo';
8
+ export const REPO_MANIFEST_LOCK_DRIFT_SCRIPT_ID = 'manifest-lock-drift';
9
+ export const REPO_MANIFEST_LOCK_DRIFT_SCRIPT_REF = `${REPO_MANIFEST_LOCK_DRIFT_PACK_ID}/${REPO_MANIFEST_LOCK_DRIFT_SCRIPT_ID}`;
10
+ export const REPO_MANIFEST_LOCK_DRIFT_LOCK_PATH = '.mustflow/config/manifest.lock.toml';
11
+ const DEFAULT_MAX_ENTRIES = 500;
12
+ const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/u;
13
+ function sha256(value) {
14
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
15
+ }
16
+ function positiveInteger(value, fallback) {
17
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
18
+ }
19
+ function normalizeRelativePath(value) {
20
+ return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '').replace(/\/+$/u, '') || '.';
21
+ }
22
+ function readOptionalString(value) {
23
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
24
+ }
25
+ function parseManifestLock(content) {
26
+ const raw = parseTomlText(content);
27
+ if (!isRecord(raw)) {
28
+ throw new Error('manifest lock must contain a TOML table');
29
+ }
30
+ const template = isRecord(raw.template) ? raw.template : {};
31
+ const files = raw.files;
32
+ if (!isRecord(files)) {
33
+ throw new Error('[files] must be a TOML table');
34
+ }
35
+ const lockedFiles = [];
36
+ for (const [relativePath, file] of Object.entries(files)) {
37
+ if (!isRecord(file)) {
38
+ throw new Error(`[files.${relativePath}] must be a TOML table`);
39
+ }
40
+ lockedFiles.push({
41
+ relativePath: normalizeRelativePath(relativePath),
42
+ source: readOptionalString(file.source),
43
+ lastAction: readOptionalString(file.last_action),
44
+ contentHash: readOptionalString(file.content_hash),
45
+ });
46
+ }
47
+ return {
48
+ schemaVersion: readOptionalString(raw.schema_version),
49
+ templateId: readOptionalString(template.id),
50
+ templateVersion: readOptionalString(template.version),
51
+ templateProfile: readOptionalString(template.profile),
52
+ templateLocale: readOptionalString(template.locale),
53
+ files: lockedFiles.sort((left, right) => left.relativePath.localeCompare(right.relativePath)),
54
+ };
55
+ }
56
+ function makeFinding(code, severity, pathValue, message, expectedHash, actualHash) {
57
+ return {
58
+ code,
59
+ severity,
60
+ path: pathValue,
61
+ expected_hash: expectedHash,
62
+ actual_hash: actualHash,
63
+ message,
64
+ json_pointer: null,
65
+ metric: null,
66
+ actual: null,
67
+ expected: null,
68
+ };
69
+ }
70
+ function resolveRequestedPaths(root, paths, findings, issues) {
71
+ const resolved = [];
72
+ const seen = new Set();
73
+ for (const inputPath of paths) {
74
+ const absolutePath = path.resolve(root, inputPath);
75
+ try {
76
+ ensureInside(root, absolutePath);
77
+ ensureInsideWithoutSymlinks(root, existsSync(absolutePath) ? absolutePath : path.dirname(absolutePath), {
78
+ allowMissingDescendant: true,
79
+ });
80
+ }
81
+ catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ issues.push(`${inputPath}: ${message}`);
84
+ findings.push(makeFinding('manifest_lock_entry_path_outside_root', 'high', inputPath, message, null, null));
85
+ continue;
86
+ }
87
+ const relativePath = normalizeRelativePath(path.relative(root, absolutePath));
88
+ if (!seen.has(relativePath)) {
89
+ seen.add(relativePath);
90
+ resolved.push(relativePath);
91
+ }
92
+ }
93
+ return resolved.sort((left, right) => left.localeCompare(right));
94
+ }
95
+ function shouldCheckEntry(entryPath, requestedPaths) {
96
+ if (requestedPaths.length === 0) {
97
+ return true;
98
+ }
99
+ return requestedPaths.some((requestedPath) => entryPath === requestedPath || entryPath.startsWith(`${requestedPath}/`));
100
+ }
101
+ function hashProjectFile(root, relativePath) {
102
+ return sha256(readFileInsideWithoutSymlinks(root, path.join(root, ...relativePath.split('/'))));
103
+ }
104
+ function safeEntryExists(root, relativePath) {
105
+ const absolutePath = path.join(root, ...relativePath.split('/'));
106
+ try {
107
+ ensureInside(root, absolutePath);
108
+ ensureInsideWithoutSymlinks(root, existsSync(absolutePath) ? absolutePath : path.dirname(absolutePath), {
109
+ allowMissingDescendant: true,
110
+ });
111
+ return existsSync(absolutePath);
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ function inspectEntry(root, lockedFile, findings) {
118
+ const relativePath = lockedFile.relativePath;
119
+ const absolutePath = path.join(root, ...relativePath.split('/'));
120
+ try {
121
+ ensureInside(root, absolutePath);
122
+ ensureInsideWithoutSymlinks(root, existsSync(absolutePath) ? absolutePath : path.dirname(absolutePath), {
123
+ allowMissingDescendant: true,
124
+ });
125
+ }
126
+ catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ findings.push(makeFinding('manifest_lock_entry_path_outside_root', 'high', relativePath, message, lockedFile.contentHash, null));
129
+ return {
130
+ path: relativePath,
131
+ source: lockedFile.source,
132
+ last_action: lockedFile.lastAction,
133
+ lock_hash: lockedFile.contentHash,
134
+ actual_hash: null,
135
+ exists: false,
136
+ status: 'unsafe_path',
137
+ };
138
+ }
139
+ if (!lockedFile.contentHash || !SHA256_PATTERN.test(lockedFile.contentHash)) {
140
+ findings.push(makeFinding('manifest_lock_entry_invalid_hash', 'high', relativePath, `Manifest lock entry ${relativePath} has an invalid content_hash.`, lockedFile.contentHash, null));
141
+ return {
142
+ path: relativePath,
143
+ source: lockedFile.source,
144
+ last_action: lockedFile.lastAction,
145
+ lock_hash: lockedFile.contentHash,
146
+ actual_hash: null,
147
+ exists: existsSync(absolutePath),
148
+ status: 'unreadable',
149
+ };
150
+ }
151
+ if (!existsSync(absolutePath)) {
152
+ findings.push(makeFinding('manifest_lock_entry_missing', 'high', relativePath, `Manifest lock entry ${relativePath} points to a missing file.`, lockedFile.contentHash, null));
153
+ return {
154
+ path: relativePath,
155
+ source: lockedFile.source,
156
+ last_action: lockedFile.lastAction,
157
+ lock_hash: lockedFile.contentHash,
158
+ actual_hash: null,
159
+ exists: false,
160
+ status: 'missing',
161
+ };
162
+ }
163
+ try {
164
+ const actualHash = hashProjectFile(root, relativePath);
165
+ if (actualHash !== lockedFile.contentHash) {
166
+ findings.push(makeFinding('manifest_lock_hash_mismatch', 'medium', relativePath, `Manifest lock content_hash differs from the current file hash for ${relativePath}.`, lockedFile.contentHash, actualHash));
167
+ return {
168
+ path: relativePath,
169
+ source: lockedFile.source,
170
+ last_action: lockedFile.lastAction,
171
+ lock_hash: lockedFile.contentHash,
172
+ actual_hash: actualHash,
173
+ exists: true,
174
+ status: 'hash_mismatch',
175
+ };
176
+ }
177
+ return {
178
+ path: relativePath,
179
+ source: lockedFile.source,
180
+ last_action: lockedFile.lastAction,
181
+ lock_hash: lockedFile.contentHash,
182
+ actual_hash: actualHash,
183
+ exists: true,
184
+ status: 'clean',
185
+ };
186
+ }
187
+ catch (error) {
188
+ const message = error instanceof Error ? error.message : String(error);
189
+ findings.push(makeFinding('manifest_lock_entry_unreadable', 'high', relativePath, message, lockedFile.contentHash, null));
190
+ return {
191
+ path: relativePath,
192
+ source: lockedFile.source,
193
+ last_action: lockedFile.lastAction,
194
+ lock_hash: lockedFile.contentHash,
195
+ actual_hash: null,
196
+ exists: true,
197
+ status: 'unreadable',
198
+ };
199
+ }
200
+ }
201
+ function summarize(entries, entriesTotal, findings) {
202
+ return {
203
+ entries_total: entriesTotal,
204
+ entries_checked: entries.filter((entry) => entry.status !== 'skipped').length,
205
+ clean_entries: entries.filter((entry) => entry.status === 'clean').length,
206
+ missing_entries: entries.filter((entry) => entry.status === 'missing').length,
207
+ hash_mismatches: entries.filter((entry) => entry.status === 'hash_mismatch').length,
208
+ unreadable_entries: entries.filter((entry) => entry.status === 'unreadable').length,
209
+ unsafe_entries: entries.filter((entry) => entry.status === 'unsafe_path').length,
210
+ skipped_entries: entries.filter((entry) => entry.status === 'skipped').length,
211
+ findings: findings.length,
212
+ };
213
+ }
214
+ function createInputHash(reportInput) {
215
+ return sha256(JSON.stringify(reportInput));
216
+ }
217
+ function createLockMetadata(lock, exists) {
218
+ return {
219
+ path: REPO_MANIFEST_LOCK_DRIFT_LOCK_PATH,
220
+ exists,
221
+ schema_version: lock?.schemaVersion ?? null,
222
+ template_id: lock?.templateId ?? null,
223
+ template_version: lock?.templateVersion ?? null,
224
+ template_profile: lock?.templateProfile ?? null,
225
+ template_locale: lock?.templateLocale ?? null,
226
+ entries_total: lock?.files.length ?? 0,
227
+ };
228
+ }
229
+ export function checkRepoManifestLockDrift(projectRoot, options) {
230
+ const root = path.resolve(projectRoot);
231
+ const findings = [];
232
+ const issues = [];
233
+ const requestedPaths = [...options.paths];
234
+ const policy = {
235
+ lock_path: REPO_MANIFEST_LOCK_DRIFT_LOCK_PATH,
236
+ input_mode: requestedPaths.length > 0 ? 'explicit_paths' : 'all_locked_files',
237
+ max_entries: positiveInteger(options.maxEntries, DEFAULT_MAX_ENTRIES),
238
+ extra_manifest_file_detection: 'not_authoritative',
239
+ };
240
+ const lockPath = path.join(root, ...REPO_MANIFEST_LOCK_DRIFT_LOCK_PATH.split('/'));
241
+ const normalizedRequestedPaths = resolveRequestedPaths(root, requestedPaths, findings, issues);
242
+ let lock = null;
243
+ if (!existsSync(lockPath)) {
244
+ const message = `Manifest lock is missing: ${REPO_MANIFEST_LOCK_DRIFT_LOCK_PATH}`;
245
+ findings.push(makeFinding('manifest_lock_missing', 'high', REPO_MANIFEST_LOCK_DRIFT_LOCK_PATH, message, null, null));
246
+ issues.push(message);
247
+ }
248
+ else {
249
+ try {
250
+ ensureInside(root, lockPath);
251
+ ensureInsideWithoutSymlinks(root, lockPath);
252
+ lock = parseManifestLock(readUtf8FileInsideWithoutSymlinks(root, lockPath, { maxBytes: 1024 * 1024 }));
253
+ }
254
+ catch (error) {
255
+ const message = error instanceof Error ? error.message : String(error);
256
+ findings.push(makeFinding('manifest_lock_invalid', 'high', REPO_MANIFEST_LOCK_DRIFT_LOCK_PATH, message, null, null));
257
+ issues.push(`Invalid manifest lock: ${message}`);
258
+ }
259
+ }
260
+ const entries = [];
261
+ if (lock) {
262
+ let maxEntriesFindingReported = false;
263
+ for (const lockedFile of lock.files) {
264
+ if (!shouldCheckEntry(lockedFile.relativePath, normalizedRequestedPaths)) {
265
+ entries.push({
266
+ path: lockedFile.relativePath,
267
+ source: lockedFile.source,
268
+ last_action: lockedFile.lastAction,
269
+ lock_hash: lockedFile.contentHash,
270
+ actual_hash: null,
271
+ exists: safeEntryExists(root, lockedFile.relativePath),
272
+ status: 'skipped',
273
+ });
274
+ continue;
275
+ }
276
+ if (entries.filter((entry) => entry.status !== 'skipped').length >= policy.max_entries) {
277
+ if (!maxEntriesFindingReported) {
278
+ const message = `Manifest lock drift check reached max_entries ${policy.max_entries}; remaining entries were skipped.`;
279
+ findings.push(makeFinding('manifest_lock_max_entries_exceeded', 'high', lockedFile.relativePath, message, lockedFile.contentHash, null));
280
+ maxEntriesFindingReported = true;
281
+ }
282
+ entries.push({
283
+ path: lockedFile.relativePath,
284
+ source: lockedFile.source,
285
+ last_action: lockedFile.lastAction,
286
+ lock_hash: lockedFile.contentHash,
287
+ actual_hash: null,
288
+ exists: safeEntryExists(root, lockedFile.relativePath),
289
+ status: 'skipped',
290
+ });
291
+ continue;
292
+ }
293
+ entries.push(inspectEntry(root, lockedFile, findings));
294
+ }
295
+ }
296
+ const lockMetadata = createLockMetadata(lock, existsSync(lockPath));
297
+ const summary = summarize(entries, lockMetadata.entries_total, findings);
298
+ const hasHighFinding = findings.some((finding) => finding.severity === 'high' || finding.severity === 'critical');
299
+ const status = issues.length > 0 || hasHighFinding ? 'error' : findings.length > 0 ? 'failed' : 'passed';
300
+ return {
301
+ schema_version: '1',
302
+ command: 'script-pack',
303
+ pack_id: REPO_MANIFEST_LOCK_DRIFT_PACK_ID,
304
+ script_id: REPO_MANIFEST_LOCK_DRIFT_SCRIPT_ID,
305
+ script_ref: REPO_MANIFEST_LOCK_DRIFT_SCRIPT_REF,
306
+ action: 'check',
307
+ status,
308
+ ok: status === 'passed',
309
+ mustflow_root: root,
310
+ input: {
311
+ paths: requestedPaths,
312
+ },
313
+ policy,
314
+ input_hash: createInputHash({ inputPaths: requestedPaths, policy, lock: lockMetadata, summary, entries, findings, issues }),
315
+ lock: lockMetadata,
316
+ summary,
317
+ entries,
318
+ findings,
319
+ issues,
320
+ };
321
+ }
@@ -0,0 +1,335 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { ensureInsideWithoutSymlinks, readUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
6
+ export const REPO_MERGE_CONFLICT_SCAN_PACK_ID = 'repo';
7
+ export const REPO_MERGE_CONFLICT_SCAN_SCRIPT_ID = 'merge-conflict-scan';
8
+ export const REPO_MERGE_CONFLICT_SCAN_SCRIPT_REF = `${REPO_MERGE_CONFLICT_SCAN_PACK_ID}/${REPO_MERGE_CONFLICT_SCAN_SCRIPT_ID}`;
9
+ const DEFAULT_MAX_FILES = 1000;
10
+ const DEFAULT_MAX_FILE_BYTES = 512 * 1024;
11
+ const MARKER_PATTERNS = [
12
+ { prefix: '<<<<<<<', marker: 'start' },
13
+ { prefix: '|||||||', marker: 'base' },
14
+ { prefix: '=======', marker: 'separator' },
15
+ { prefix: '>>>>>>>', marker: 'end' },
16
+ ];
17
+ const SKIPPED_DIRECTORY_NAMES = new Set([
18
+ '.git',
19
+ 'node_modules',
20
+ 'vendor',
21
+ 'third_party',
22
+ 'dist',
23
+ 'build',
24
+ 'coverage',
25
+ '.mustflow/cache',
26
+ '.mustflow/state',
27
+ ]);
28
+ function normalizeRelativePath(value) {
29
+ return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '').replace(/\/+$/u, '') || '.';
30
+ }
31
+ function sha256(value) {
32
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
33
+ }
34
+ function positiveInteger(value, fallback) {
35
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
36
+ }
37
+ function runGit(root, args) {
38
+ const result = spawnSync('git', [...args], {
39
+ cwd: root,
40
+ encoding: 'utf8',
41
+ stdio: ['ignore', 'pipe', 'pipe'],
42
+ windowsHide: true,
43
+ maxBuffer: 16 * 1024 * 1024,
44
+ });
45
+ return {
46
+ ok: result.status === 0,
47
+ stdout: result.stdout ?? '',
48
+ stderr: result.stderr ?? '',
49
+ };
50
+ }
51
+ function isInsideGitWorktree(root) {
52
+ const result = runGit(root, ['rev-parse', '--is-inside-work-tree']);
53
+ return result.ok && result.stdout.trim() === 'true';
54
+ }
55
+ function parseGitPaths(stdout) {
56
+ const paths = [];
57
+ const seen = new Set();
58
+ for (const line of stdout.split(/\r?\n/u)) {
59
+ const relativePath = normalizeRelativePath(line.trim());
60
+ if (!relativePath || relativePath === '.' || seen.has(relativePath)) {
61
+ continue;
62
+ }
63
+ seen.add(relativePath);
64
+ paths.push(relativePath);
65
+ }
66
+ return paths.sort((left, right) => left.localeCompare(right));
67
+ }
68
+ function collectGitChangedFiles(root, issues, findings) {
69
+ if (!isInsideGitWorktree(root)) {
70
+ const message = 'Git worktree is unavailable; provide explicit paths to scan.';
71
+ issues.push(message);
72
+ findings.push(makeFinding('merge_conflict_scan_git_unavailable', 'low', '.', message, null, null));
73
+ return [];
74
+ }
75
+ const tracked = runGit(root, ['diff', '--name-only', '--diff-filter=ACMRTUXB', 'HEAD', '--']);
76
+ const untracked = runGit(root, ['ls-files', '--others', '--exclude-standard', '--']);
77
+ const paths = new Set();
78
+ if (tracked.ok) {
79
+ for (const entry of parseGitPaths(tracked.stdout)) {
80
+ paths.add(entry);
81
+ }
82
+ }
83
+ else {
84
+ issues.push(`Could not inspect changed tracked files: ${tracked.stderr.trim() || 'git diff failed'}`);
85
+ }
86
+ if (untracked.ok) {
87
+ for (const entry of parseGitPaths(untracked.stdout)) {
88
+ paths.add(entry);
89
+ }
90
+ }
91
+ else {
92
+ issues.push(`Could not inspect untracked files: ${untracked.stderr.trim() || 'git ls-files failed'}`);
93
+ }
94
+ return [...paths].sort((left, right) => left.localeCompare(right));
95
+ }
96
+ function shouldSkipDirectory(relativePath) {
97
+ const normalized = normalizeRelativePath(relativePath);
98
+ const firstSegment = normalized.split('/')[0] ?? normalized;
99
+ return SKIPPED_DIRECTORY_NAMES.has(normalized) || SKIPPED_DIRECTORY_NAMES.has(firstSegment);
100
+ }
101
+ function isLikelyTextFile(relativePath) {
102
+ const extension = path.posix.extname(relativePath).toLowerCase();
103
+ if (!extension) {
104
+ return true;
105
+ }
106
+ return ![
107
+ '.png',
108
+ '.jpg',
109
+ '.jpeg',
110
+ '.gif',
111
+ '.webp',
112
+ '.ico',
113
+ '.pdf',
114
+ '.zip',
115
+ '.gz',
116
+ '.tgz',
117
+ '.wasm',
118
+ '.sqlite',
119
+ '.db',
120
+ '.lockb',
121
+ ].includes(extension);
122
+ }
123
+ function listFilesUnder(root, relativePath, findings, issues) {
124
+ const absolutePath = path.join(root, ...relativePath.split('/'));
125
+ let entries;
126
+ try {
127
+ ensureInsideWithoutSymlinks(root, absolutePath);
128
+ entries = readdirSync(absolutePath, { withFileTypes: true });
129
+ }
130
+ catch (error) {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ issues.push(`${relativePath}: ${message}`);
133
+ findings.push(makeFinding('merge_conflict_scan_unreadable_path', 'high', relativePath, message, null, null));
134
+ return [];
135
+ }
136
+ const files = [];
137
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
138
+ const child = normalizeRelativePath(relativePath === '.' ? entry.name : `${relativePath}/${entry.name}`);
139
+ if (entry.isDirectory()) {
140
+ if (!shouldSkipDirectory(child)) {
141
+ files.push(...listFilesUnder(root, child, findings, issues));
142
+ }
143
+ continue;
144
+ }
145
+ if (entry.isFile() && isLikelyTextFile(child)) {
146
+ files.push(child);
147
+ }
148
+ }
149
+ return files;
150
+ }
151
+ function resolveInputFiles(root, inputPaths, policy, findings, issues) {
152
+ const files = [];
153
+ const seen = new Set();
154
+ for (const inputPath of inputPaths) {
155
+ const absolutePath = path.resolve(root, inputPath);
156
+ let relativePath;
157
+ try {
158
+ ensureInsideWithoutSymlinks(root, absolutePath);
159
+ relativePath = normalizeRelativePath(path.relative(root, absolutePath));
160
+ }
161
+ catch (error) {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ issues.push(`${inputPath}: ${message}`);
164
+ findings.push(makeFinding('merge_conflict_scan_path_outside_root', 'high', inputPath, message, null, null));
165
+ continue;
166
+ }
167
+ if (!existsSync(absolutePath)) {
168
+ continue;
169
+ }
170
+ let stats;
171
+ try {
172
+ stats = lstatSync(absolutePath);
173
+ }
174
+ catch (error) {
175
+ const message = error instanceof Error ? error.message : String(error);
176
+ issues.push(`${relativePath}: ${message}`);
177
+ findings.push(makeFinding('merge_conflict_scan_unreadable_path', 'high', relativePath, message, null, null));
178
+ continue;
179
+ }
180
+ const candidates = stats.isDirectory()
181
+ ? listFilesUnder(root, relativePath, findings, issues)
182
+ : stats.isFile() && isLikelyTextFile(relativePath)
183
+ ? [relativePath]
184
+ : [];
185
+ for (const candidate of candidates) {
186
+ if (seen.has(candidate)) {
187
+ continue;
188
+ }
189
+ if (files.length >= policy.max_files) {
190
+ const message = `Merge-conflict scan reached max_files ${policy.max_files}; remaining files were skipped.`;
191
+ issues.push(message);
192
+ findings.push(makeFinding('merge_conflict_scan_max_files_exceeded', 'high', candidate, message, null, null));
193
+ return files;
194
+ }
195
+ seen.add(candidate);
196
+ files.push(candidate);
197
+ }
198
+ }
199
+ return files.sort((left, right) => left.localeCompare(right));
200
+ }
201
+ function markerForLine(line) {
202
+ const trimmedStart = line.trimStart();
203
+ for (const pattern of MARKER_PATTERNS) {
204
+ if (trimmedStart.startsWith(pattern.prefix)) {
205
+ return { marker: pattern.marker, markerLength: pattern.prefix.length };
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+ function makeFinding(code, severity, pathValue, message, line, marker) {
211
+ return {
212
+ code,
213
+ severity,
214
+ path: pathValue,
215
+ line,
216
+ marker,
217
+ message,
218
+ json_pointer: null,
219
+ metric: null,
220
+ actual: null,
221
+ expected: null,
222
+ };
223
+ }
224
+ function scanFile(root, relativePath, policy, findings, issues) {
225
+ let content;
226
+ try {
227
+ content = readUtf8FileInsideWithoutSymlinks(root, path.join(root, ...relativePath.split('/')), {
228
+ maxBytes: policy.max_file_bytes,
229
+ });
230
+ }
231
+ catch (error) {
232
+ const message = error instanceof Error ? error.message : String(error);
233
+ const code = message.includes('exceeds maximum size')
234
+ ? 'merge_conflict_scan_file_too_large'
235
+ : 'merge_conflict_scan_unreadable_path';
236
+ const severity = code === 'merge_conflict_scan_file_too_large' ? 'medium' : 'high';
237
+ issues.push(`${relativePath}: ${message}`);
238
+ findings.push(makeFinding(code, severity, relativePath, message, null, null));
239
+ return { file: null, markers: [] };
240
+ }
241
+ const fileMarkers = [];
242
+ const lines = content.split(/\r\n|\n|\r/u);
243
+ for (const [index, line] of lines.entries()) {
244
+ const marker = markerForLine(line);
245
+ if (!marker) {
246
+ continue;
247
+ }
248
+ const column = line.length - line.trimStart().length + 1;
249
+ const lineNumber = index + 1;
250
+ fileMarkers.push({
251
+ path: relativePath,
252
+ line: lineNumber,
253
+ column,
254
+ marker: marker.marker,
255
+ marker_length: marker.markerLength,
256
+ });
257
+ findings.push(makeFinding('merge_conflict_marker_detected', 'high', relativePath, `Merge conflict marker "${marker.marker}" detected at ${relativePath}:${lineNumber}.`, lineNumber, marker.marker));
258
+ }
259
+ return {
260
+ file: {
261
+ path: relativePath,
262
+ sha256: sha256(content),
263
+ bytes: Buffer.byteLength(content, 'utf8'),
264
+ markers: fileMarkers.length,
265
+ },
266
+ markers: fileMarkers,
267
+ };
268
+ }
269
+ function summarize(files, markers, issues) {
270
+ return {
271
+ files_checked: files.length,
272
+ markers_found: markers.length,
273
+ files_with_markers: files.filter((file) => file.markers > 0).length,
274
+ issues: issues.length,
275
+ };
276
+ }
277
+ function createInputHash(reportInput) {
278
+ return sha256(JSON.stringify(reportInput));
279
+ }
280
+ export function checkRepoMergeConflictScan(projectRoot, options) {
281
+ const root = path.resolve(projectRoot);
282
+ const inputPaths = options.paths.length > 0 ? [...options.paths] : [];
283
+ const findings = [];
284
+ const issues = [];
285
+ const policy = {
286
+ input_mode: inputPaths.length > 0 ? 'explicit_paths' : 'git_changed_files',
287
+ marker_prefixes: MARKER_PATTERNS.map((pattern) => pattern.prefix),
288
+ max_files: positiveInteger(options.maxFiles, DEFAULT_MAX_FILES),
289
+ max_file_bytes: positiveInteger(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES),
290
+ skipped_directories: [...SKIPPED_DIRECTORY_NAMES].sort((left, right) => left.localeCompare(right)),
291
+ };
292
+ const effectivePaths = inputPaths.length > 0 ? inputPaths : collectGitChangedFiles(root, issues, findings);
293
+ const scanTargets = resolveInputFiles(root, effectivePaths, policy, findings, issues);
294
+ const files = [];
295
+ const markers = [];
296
+ for (const target of scanTargets) {
297
+ const result = scanFile(root, target, policy, findings, issues);
298
+ if (result.file) {
299
+ files.push(result.file);
300
+ }
301
+ markers.push(...result.markers);
302
+ }
303
+ if (effectivePaths.length === 0 && issues.length === 0) {
304
+ const message = 'No input files were available for merge-conflict scanning.';
305
+ findings.push(makeFinding('merge_conflict_scan_no_input_files', 'low', '.', message, null, null));
306
+ }
307
+ const summary = summarize(files, markers, issues);
308
+ const hasHighFinding = findings.some((finding) => finding.severity === 'high' || finding.severity === 'critical');
309
+ const status = findings.some((finding) => finding.code === 'merge_conflict_scan_path_outside_root')
310
+ ? 'error'
311
+ : hasHighFinding
312
+ ? 'failed'
313
+ : 'passed';
314
+ return {
315
+ schema_version: '1',
316
+ command: 'script-pack',
317
+ pack_id: REPO_MERGE_CONFLICT_SCAN_PACK_ID,
318
+ script_id: REPO_MERGE_CONFLICT_SCAN_SCRIPT_ID,
319
+ script_ref: REPO_MERGE_CONFLICT_SCAN_SCRIPT_REF,
320
+ action: 'check',
321
+ status,
322
+ ok: status === 'passed',
323
+ mustflow_root: root,
324
+ input: {
325
+ paths: inputPaths,
326
+ },
327
+ policy,
328
+ input_hash: createInputHash({ inputPaths, policy, files, markers, findings, issues }),
329
+ summary,
330
+ files,
331
+ markers,
332
+ findings,
333
+ issues,
334
+ };
335
+ }