mustflow 2.85.4 → 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 (78) hide show
  1. package/dist/cli/commands/script-pack.js +10 -0
  2. package/dist/cli/i18n/en.js +183 -0
  3. package/dist/cli/i18n/es.js +183 -0
  4. package/dist/cli/i18n/fr.js +183 -0
  5. package/dist/cli/i18n/hi.js +183 -0
  6. package/dist/cli/i18n/ko.js +183 -0
  7. package/dist/cli/i18n/zh.js +183 -0
  8. package/dist/cli/lib/script-pack-registry.js +284 -1
  9. package/dist/cli/script-packs/code-change-impact.js +6 -0
  10. package/dist/cli/script-packs/code-import-cycle.js +193 -0
  11. package/dist/cli/script-packs/docs-link-integrity.js +145 -0
  12. package/dist/cli/script-packs/repo-approval-gate.js +100 -0
  13. package/dist/cli/script-packs/repo-git-ignore-audit.js +119 -0
  14. package/dist/cli/script-packs/repo-manifest-lock-drift.js +122 -0
  15. package/dist/cli/script-packs/repo-merge-conflict-scan.js +123 -0
  16. package/dist/cli/script-packs/repo-skill-route-audit.js +86 -0
  17. package/dist/cli/script-packs/repo-version-source.js +92 -0
  18. package/dist/cli/script-packs/test-performance-report.js +247 -0
  19. package/dist/cli/script-packs/test-regression-selector.js +167 -0
  20. package/dist/core/change-impact.js +23 -51
  21. package/dist/core/change-surface-classification.js +198 -0
  22. package/dist/core/docs-link-integrity.js +443 -0
  23. package/dist/core/import-cycle.js +152 -0
  24. package/dist/core/public-json-contracts.js +116 -0
  25. package/dist/core/repo-approval-gate.js +116 -0
  26. package/dist/core/repo-git-ignore-audit.js +302 -0
  27. package/dist/core/repo-manifest-lock-drift.js +321 -0
  28. package/dist/core/repo-merge-conflict-scan.js +335 -0
  29. package/dist/core/repo-version-source.js +82 -0
  30. package/dist/core/script-pack-suggestions.js +77 -1
  31. package/dist/core/skill-route-audit.js +354 -0
  32. package/dist/core/test-performance-report.js +697 -0
  33. package/dist/core/test-regression-selector.js +335 -0
  34. package/package.json +1 -1
  35. package/schemas/README.md +40 -2
  36. package/schemas/change-impact-report.schema.json +35 -1
  37. package/schemas/import-cycle-report.schema.json +157 -0
  38. package/schemas/link-integrity-report.schema.json +176 -0
  39. package/schemas/repo-approval-gate-report.schema.json +115 -0
  40. package/schemas/repo-git-ignore-audit-report.schema.json +201 -0
  41. package/schemas/repo-manifest-lock-drift-report.schema.json +202 -0
  42. package/schemas/repo-merge-conflict-scan-report.schema.json +169 -0
  43. package/schemas/repo-version-source-report.schema.json +127 -0
  44. package/schemas/skill-route-audit-report.schema.json +144 -0
  45. package/schemas/test-performance-report.schema.json +319 -0
  46. package/schemas/test-regression-selector-report.schema.json +187 -0
  47. package/templates/default/i18n.toml +66 -18
  48. package/templates/default/locales/en/.mustflow/skills/INDEX.md +45 -8
  49. package/templates/default/locales/en/.mustflow/skills/api-access-control-review/SKILL.md +48 -27
  50. package/templates/default/locales/en/.mustflow/skills/api-failure-triage/SKILL.md +270 -0
  51. package/templates/default/locales/en/.mustflow/skills/auth-flow-triage/SKILL.md +192 -0
  52. package/templates/default/locales/en/.mustflow/skills/auth-permission-change/SKILL.md +59 -13
  53. package/templates/default/locales/en/.mustflow/skills/backend-log-evidence-review/SKILL.md +14 -5
  54. package/templates/default/locales/en/.mustflow/skills/cache-integrity-review/SKILL.md +30 -15
  55. package/templates/default/locales/en/.mustflow/skills/change-blast-radius-review/SKILL.md +45 -32
  56. package/templates/default/locales/en/.mustflow/skills/ci-pipeline-triage/SKILL.md +200 -0
  57. package/templates/default/locales/en/.mustflow/skills/clarifying-question-gate/SKILL.md +87 -13
  58. package/templates/default/locales/en/.mustflow/skills/docker-runtime-triage/SKILL.md +191 -0
  59. package/templates/default/locales/en/.mustflow/skills/go-code-change/SKILL.md +18 -13
  60. package/templates/default/locales/en/.mustflow/skills/line-ending-hygiene/SKILL.md +18 -10
  61. package/templates/default/locales/en/.mustflow/skills/llm-hallucination-control-review/SKILL.md +4 -1
  62. package/templates/default/locales/en/.mustflow/skills/motion-system-contract-review/SKILL.md +155 -0
  63. package/templates/default/locales/en/.mustflow/skills/next-action-menu/SKILL.md +177 -0
  64. package/templates/default/locales/en/.mustflow/skills/observability-debuggability-review/SKILL.md +15 -7
  65. package/templates/default/locales/en/.mustflow/skills/payment-integrity-review/SKILL.md +59 -35
  66. package/templates/default/locales/en/.mustflow/skills/powershell-code-change/SKILL.md +16 -6
  67. package/templates/default/locales/en/.mustflow/skills/prompt-contract-quality-review/SKILL.md +4 -1
  68. package/templates/default/locales/en/.mustflow/skills/python-code-change/SKILL.md +19 -10
  69. package/templates/default/locales/en/.mustflow/skills/rag-pipeline-triage/SKILL.md +206 -0
  70. package/templates/default/locales/en/.mustflow/skills/routes.toml +54 -0
  71. package/templates/default/locales/en/.mustflow/skills/rust-code-change/SKILL.md +10 -4
  72. package/templates/default/locales/en/.mustflow/skills/search-index-integrity-review/SKILL.md +181 -0
  73. package/templates/default/locales/en/.mustflow/skills/service-boundary-architecture/SKILL.md +37 -23
  74. package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +9 -0
  75. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +14 -9
  76. package/templates/default/locales/en/.mustflow/skills/vector-search-integrity-review/SKILL.md +209 -0
  77. package/templates/default/locales/en/.mustflow/skills/version-freshness-check/SKILL.md +16 -14
  78. package/templates/default/manifest.toml +64 -1
@@ -0,0 +1,302 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { ensureInside, ensureInsideWithoutSymlinks } from './safe-filesystem.js';
7
+ export const REPO_GIT_IGNORE_AUDIT_PACK_ID = 'repo';
8
+ export const REPO_GIT_IGNORE_AUDIT_SCRIPT_ID = 'git-ignore-audit';
9
+ export const REPO_GIT_IGNORE_AUDIT_SCRIPT_REF = `${REPO_GIT_IGNORE_AUDIT_PACK_ID}/${REPO_GIT_IGNORE_AUDIT_SCRIPT_ID}`;
10
+ const DEFAULT_MAX_PATHS = 200;
11
+ const SOURCE_KINDS = [
12
+ 'repo_gitignore',
13
+ 'git_info_exclude',
14
+ 'core_excludes_file',
15
+ ];
16
+ function normalizeRelativePath(value) {
17
+ return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '').replace(/\/+$/u, '') || '.';
18
+ }
19
+ function sha256(value) {
20
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
21
+ }
22
+ function positiveInteger(value, fallback) {
23
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
24
+ }
25
+ function runGit(root, args) {
26
+ const result = spawnSync('git', [...args], {
27
+ cwd: root,
28
+ encoding: 'utf8',
29
+ stdio: ['ignore', 'pipe', 'pipe'],
30
+ windowsHide: true,
31
+ maxBuffer: 16 * 1024 * 1024,
32
+ });
33
+ return {
34
+ ok: result.status === 0,
35
+ status: result.status,
36
+ stdout: result.stdout ?? '',
37
+ stderr: result.stderr ?? '',
38
+ };
39
+ }
40
+ function isInsideGitWorktree(root) {
41
+ const result = runGit(root, ['rev-parse', '--is-inside-work-tree']);
42
+ return result.ok && result.stdout.trim() === 'true';
43
+ }
44
+ function parseGitPaths(stdout) {
45
+ const paths = [];
46
+ const seen = new Set();
47
+ for (const line of stdout.split(/\r?\n/u)) {
48
+ const relativePath = normalizeRelativePath(line.trim());
49
+ if (!relativePath || relativePath === '.' || seen.has(relativePath)) {
50
+ continue;
51
+ }
52
+ seen.add(relativePath);
53
+ paths.push(relativePath);
54
+ }
55
+ return paths.sort((left, right) => left.localeCompare(right));
56
+ }
57
+ function collectGitChangedFiles(root, issues, findings) {
58
+ if (!isInsideGitWorktree(root)) {
59
+ const message = 'Git worktree is unavailable; provide explicit paths to audit.';
60
+ issues.push(message);
61
+ findings.push(makeFinding('git_ignore_audit_git_unavailable', 'low', '.', message, null));
62
+ return [];
63
+ }
64
+ const tracked = runGit(root, ['diff', '--name-only', '--diff-filter=ACMRTUXB', 'HEAD', '--']);
65
+ const untracked = runGit(root, ['ls-files', '--others', '--exclude-standard', '--']);
66
+ const paths = new Set();
67
+ if (tracked.ok) {
68
+ for (const entry of parseGitPaths(tracked.stdout)) {
69
+ paths.add(entry);
70
+ }
71
+ }
72
+ else {
73
+ issues.push(`Could not inspect changed tracked files: ${tracked.stderr.trim() || 'git diff failed'}`);
74
+ }
75
+ if (untracked.ok) {
76
+ for (const entry of parseGitPaths(untracked.stdout)) {
77
+ paths.add(entry);
78
+ }
79
+ }
80
+ else {
81
+ issues.push(`Could not inspect untracked files: ${untracked.stderr.trim() || 'git ls-files failed'}`);
82
+ }
83
+ return [...paths].sort((left, right) => left.localeCompare(right));
84
+ }
85
+ function resolveAuditPaths(root, inputPaths, maxPaths, findings, issues) {
86
+ const paths = [];
87
+ const seen = new Set();
88
+ for (const inputPath of inputPaths) {
89
+ const absolutePath = path.resolve(root, inputPath);
90
+ let relativePath;
91
+ try {
92
+ ensureInside(root, absolutePath);
93
+ ensureInsideWithoutSymlinks(root, existsSync(absolutePath) ? absolutePath : path.dirname(absolutePath), {
94
+ allowMissingDescendant: true,
95
+ });
96
+ relativePath = normalizeRelativePath(path.relative(root, absolutePath));
97
+ }
98
+ catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ issues.push(`${inputPath}: ${message}`);
101
+ findings.push(makeFinding('git_ignore_audit_path_outside_root', 'high', inputPath, message, null));
102
+ continue;
103
+ }
104
+ if (seen.has(relativePath)) {
105
+ continue;
106
+ }
107
+ if (paths.length >= maxPaths) {
108
+ const message = `Git ignore audit reached max_paths ${maxPaths}; remaining paths were skipped.`;
109
+ issues.push(message);
110
+ findings.push(makeFinding('git_ignore_audit_max_paths_exceeded', 'high', relativePath, message, null));
111
+ return paths;
112
+ }
113
+ seen.add(relativePath);
114
+ paths.push(relativePath);
115
+ }
116
+ return paths.sort((left, right) => left.localeCompare(right));
117
+ }
118
+ function expandHome(value) {
119
+ if (value === '~') {
120
+ return os.homedir();
121
+ }
122
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
123
+ return path.join(os.homedir(), value.slice(2));
124
+ }
125
+ return value;
126
+ }
127
+ function readExternalCoreExcludesPath(root) {
128
+ const local = runGit(root, ['config', '--get', 'core.excludesFile']);
129
+ if (!local.ok || local.stdout.trim().length === 0) {
130
+ return null;
131
+ }
132
+ return expandHome(local.stdout.trim());
133
+ }
134
+ function makeSource(root, sourcePath, kind, scope) {
135
+ const absolutePath = scope === 'external' ? path.resolve(expandHome(sourcePath)) : path.join(root, ...sourcePath.split('/'));
136
+ const exists = existsSync(absolutePath);
137
+ if (!exists || scope === 'external') {
138
+ return {
139
+ path: scope === 'external' ? sourcePath : normalizeRelativePath(sourcePath),
140
+ kind,
141
+ exists,
142
+ scope,
143
+ sha256: null,
144
+ };
145
+ }
146
+ try {
147
+ const stats = lstatSync(absolutePath);
148
+ if (!stats.isFile()) {
149
+ return { path: normalizeRelativePath(sourcePath), kind, exists: true, scope, sha256: null };
150
+ }
151
+ return {
152
+ path: normalizeRelativePath(sourcePath),
153
+ kind,
154
+ exists: true,
155
+ scope,
156
+ sha256: sha256(readFileSync(absolutePath)),
157
+ };
158
+ }
159
+ catch {
160
+ return { path: normalizeRelativePath(sourcePath), kind, exists: true, scope, sha256: null };
161
+ }
162
+ }
163
+ function inspectIgnoreSources(root) {
164
+ const sources = [
165
+ makeSource(root, '.gitignore', 'repo_gitignore', 'repository'),
166
+ makeSource(root, '.git/info/exclude', 'git_info_exclude', 'local_git'),
167
+ ];
168
+ const coreExcludesPath = readExternalCoreExcludesPath(root);
169
+ if (coreExcludesPath) {
170
+ sources.push(makeSource(root, coreExcludesPath, 'core_excludes_file', 'external'));
171
+ }
172
+ return sources;
173
+ }
174
+ function parseIgnoreMatch(output) {
175
+ const line = output.split(/\r?\n/u).find((entry) => entry.trim().length > 0);
176
+ if (!line) {
177
+ return null;
178
+ }
179
+ const tabIndex = line.indexOf('\t');
180
+ const evidence = tabIndex >= 0 ? line.slice(0, tabIndex) : line;
181
+ const match = /^(?<sourcePath>.*):(?<sourceLine>\d+):(?<pattern>.*)$/u.exec(evidence);
182
+ const sourcePath = match?.groups?.sourcePath ?? '';
183
+ const sourceLine = Number(match?.groups?.sourceLine);
184
+ const pattern = match?.groups?.pattern ?? '';
185
+ if (!Number.isInteger(sourceLine) || sourceLine < 1 || pattern.length === 0) {
186
+ return null;
187
+ }
188
+ return { sourcePath: normalizeRelativePath(sourcePath), sourceLine, pattern };
189
+ }
190
+ function checkIgnoreMatch(root, relativePath, issues) {
191
+ const result = runGit(root, ['check-ignore', '-v', '--no-index', '--', relativePath]);
192
+ if (result.ok) {
193
+ return parseIgnoreMatch(result.stdout);
194
+ }
195
+ if (result.status !== 1 && result.stderr.trim().length > 0) {
196
+ issues.push(`${relativePath}: ${result.stderr.trim()}`);
197
+ }
198
+ return null;
199
+ }
200
+ function isTrackedPath(root, relativePath) {
201
+ const result = runGit(root, ['ls-files', '--error-unmatch', '--', relativePath]);
202
+ return result.ok;
203
+ }
204
+ function pathExists(root, relativePath) {
205
+ return existsSync(path.join(root, ...relativePath.split('/')));
206
+ }
207
+ function makeFinding(code, severity, pathValue, message, match) {
208
+ return {
209
+ code,
210
+ severity,
211
+ path: pathValue,
212
+ source_path: match?.sourcePath ?? null,
213
+ source_line: match?.sourceLine ?? null,
214
+ pattern: match?.pattern ?? null,
215
+ message,
216
+ json_pointer: null,
217
+ metric: null,
218
+ actual: null,
219
+ expected: null,
220
+ };
221
+ }
222
+ function auditPath(root, relativePath, findings, issues) {
223
+ const match = checkIgnoreMatch(root, relativePath, issues);
224
+ const tracked = isTrackedPath(root, relativePath);
225
+ const exists = pathExists(root, relativePath);
226
+ const ignored = match !== null;
227
+ const status = tracked ? 'tracked' : ignored ? 'ignored' : exists ? 'untracked' : 'missing';
228
+ if (ignored && tracked) {
229
+ findings.push(makeFinding('git_ignore_audit_tracked_path_matches_ignore', 'low', relativePath, `Tracked path ${relativePath} matches an ignore rule, but Git will keep tracking it.`, match));
230
+ }
231
+ else if (ignored) {
232
+ findings.push(makeFinding('git_ignore_audit_ignored_path', 'medium', relativePath, `Path ${relativePath} is ignored by ${match.sourcePath}:${match.sourceLine}.`, match));
233
+ }
234
+ return {
235
+ path: relativePath,
236
+ status,
237
+ tracked,
238
+ ignored,
239
+ exists,
240
+ source_path: match?.sourcePath ?? null,
241
+ source_line: match?.sourceLine ?? null,
242
+ pattern: match?.pattern ?? null,
243
+ };
244
+ }
245
+ function summarize(paths, sources, findings) {
246
+ return {
247
+ paths_checked: paths.length,
248
+ ignored_paths: paths.filter((entry) => entry.ignored).length,
249
+ tracked_paths: paths.filter((entry) => entry.tracked).length,
250
+ untracked_paths: paths.filter((entry) => entry.status === 'untracked').length,
251
+ missing_paths: paths.filter((entry) => entry.status === 'missing').length,
252
+ ignore_sources: sources.filter((source) => source.exists).length,
253
+ findings: findings.length,
254
+ };
255
+ }
256
+ function createInputHash(reportInput) {
257
+ return sha256(JSON.stringify(reportInput));
258
+ }
259
+ export function auditRepoGitIgnore(projectRoot, options) {
260
+ const root = path.resolve(projectRoot);
261
+ const requestedPaths = options.paths.length > 0 ? [...options.paths] : [];
262
+ const findings = [];
263
+ const issues = [];
264
+ const policy = {
265
+ input_mode: requestedPaths.length > 0 ? 'explicit_paths' : 'git_changed_files',
266
+ max_paths: positiveInteger(options.maxPaths, DEFAULT_MAX_PATHS),
267
+ source_kinds: SOURCE_KINDS,
268
+ tracked_paths_can_match_ignore: true,
269
+ };
270
+ const effectivePaths = requestedPaths.length > 0 ? requestedPaths : collectGitChangedFiles(root, issues, findings);
271
+ const auditTargets = resolveAuditPaths(root, effectivePaths, policy.max_paths, findings, issues);
272
+ const sources = inspectIgnoreSources(root);
273
+ const paths = auditTargets.map((entry) => auditPath(root, entry, findings, issues));
274
+ if (effectivePaths.length === 0 && issues.length === 0) {
275
+ const message = 'No input paths were available for Git ignore auditing.';
276
+ findings.push(makeFinding('git_ignore_audit_no_input_paths', 'low', '.', message, null));
277
+ }
278
+ const summary = summarize(paths, sources, findings);
279
+ const hasHighFinding = findings.some((finding) => finding.severity === 'high' || finding.severity === 'critical');
280
+ const status = hasHighFinding || issues.length > 0 ? 'error' : findings.length > 0 ? 'failed' : 'passed';
281
+ return {
282
+ schema_version: '1',
283
+ command: 'script-pack',
284
+ pack_id: REPO_GIT_IGNORE_AUDIT_PACK_ID,
285
+ script_id: REPO_GIT_IGNORE_AUDIT_SCRIPT_ID,
286
+ script_ref: REPO_GIT_IGNORE_AUDIT_SCRIPT_REF,
287
+ action: 'audit',
288
+ status,
289
+ ok: status === 'passed',
290
+ mustflow_root: root,
291
+ input: {
292
+ paths: requestedPaths,
293
+ },
294
+ policy,
295
+ input_hash: createInputHash({ inputPaths: requestedPaths, policy, sources, paths, findings, issues }),
296
+ summary,
297
+ sources,
298
+ paths,
299
+ findings,
300
+ issues,
301
+ };
302
+ }
@@ -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
+ }