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,198 @@
1
+ import path from 'node:path';
2
+ export const CHANGE_SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
3
+ const PACKAGE_LOCKFILES = new Set(['bun.lock', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'deno.lock']);
4
+ function toPosixPath(value) {
5
+ return value.replace(/\\/gu, '/');
6
+ }
7
+ export function normalizeChangePath(value) {
8
+ return toPosixPath(value).replace(/^\.\/+/u, '') || '.';
9
+ }
10
+ export function statusFromGitNameStatus(code) {
11
+ if (code.startsWith('A')) {
12
+ return 'added';
13
+ }
14
+ if (code.startsWith('D')) {
15
+ return 'deleted';
16
+ }
17
+ if (code.startsWith('R')) {
18
+ return 'renamed';
19
+ }
20
+ if (code.startsWith('T')) {
21
+ return 'type_changed';
22
+ }
23
+ return 'modified';
24
+ }
25
+ export function classifyChangeSurface(relativePath, options = {}) {
26
+ const normalized = normalizeChangePath(relativePath);
27
+ const extension = path.extname(normalized).toLowerCase();
28
+ const basename = path.basename(normalized).toLowerCase();
29
+ if (normalized.startsWith('.github/workflows/')) {
30
+ return 'workflow';
31
+ }
32
+ if (normalized.startsWith('.mustflow/')) {
33
+ return 'workflow';
34
+ }
35
+ if (normalized.startsWith('templates/')) {
36
+ return 'template';
37
+ }
38
+ if (normalized.startsWith('schemas/') || basename.endsWith('.schema.json')) {
39
+ return 'schema';
40
+ }
41
+ if (normalized.startsWith('docs') ||
42
+ (options.includeDocsSite === true && normalized.startsWith('docs-site/')) ||
43
+ ['.md', '.mdx'].includes(extension)) {
44
+ return 'docs';
45
+ }
46
+ if (normalized.includes('/i18n/') || normalized.includes('/locales/') || basename.includes('i18n')) {
47
+ return 'i18n';
48
+ }
49
+ if (normalized === 'package.json' || PACKAGE_LOCKFILES.has(normalized)) {
50
+ return 'package';
51
+ }
52
+ if (/^(?:tsconfig|eslint|vite|vitest|jest|playwright|astro|svelte|tailwind)\b/u.test(basename)) {
53
+ return 'config';
54
+ }
55
+ if (normalized.startsWith('tests/') ||
56
+ /\.test\.[cm]?[jt]sx?$/u.test(normalized) ||
57
+ /\.spec\.[cm]?[jt]sx?$/u.test(normalized)) {
58
+ return 'test';
59
+ }
60
+ if (CHANGE_SOURCE_EXTENSIONS.includes(extension)) {
61
+ return 'source';
62
+ }
63
+ return 'unknown';
64
+ }
65
+ export function selectorFallbackReasonForChangedPath(changedPath) {
66
+ const normalized = normalizeChangePath(changedPath.path);
67
+ const basename = path.posix.basename(normalized).toLowerCase();
68
+ if (changedPath.status === 'deleted') {
69
+ return 'fallback_deleted';
70
+ }
71
+ if (changedPath.status === 'renamed') {
72
+ return 'fallback_renamed';
73
+ }
74
+ if (changedPath.status === 'type_changed') {
75
+ return 'fallback_type_changed';
76
+ }
77
+ if (PACKAGE_LOCKFILES.has(normalized)) {
78
+ return 'fallback_lockfile';
79
+ }
80
+ if (basename === 'package.json') {
81
+ return 'fallback_package_metadata';
82
+ }
83
+ if (changedPath.surface === 'template') {
84
+ return 'fallback_template';
85
+ }
86
+ if (isGeneratedContractPath(normalized)) {
87
+ return 'fallback_generated_contract';
88
+ }
89
+ if (isSharedTestFixturePath(normalized)) {
90
+ return 'fallback_shared_test_fixture';
91
+ }
92
+ if (isMigrationOrDatabasePath(normalized)) {
93
+ return 'fallback_migration_or_database';
94
+ }
95
+ if (normalized.startsWith('.github/workflows/')) {
96
+ return 'fallback_ci_workflow';
97
+ }
98
+ if (normalized.startsWith('.mustflow/')) {
99
+ return 'fallback_mustflow_workflow';
100
+ }
101
+ if (isCompilerOrRunnerConfigPath(normalized) || changedPath.surface === 'config') {
102
+ return 'fallback_compiler_or_runner_config';
103
+ }
104
+ if (changedPath.surface === 'unknown') {
105
+ return 'fallback_unknown';
106
+ }
107
+ return null;
108
+ }
109
+ export function selectorFallbackForChangedPath(changedPath) {
110
+ const reason = selectorFallbackReasonForChangedPath(changedPath);
111
+ if (reason === null) {
112
+ return null;
113
+ }
114
+ return {
115
+ reason,
116
+ path: changedPath.path,
117
+ message: selectorFallbackMessage(reason),
118
+ recommended_intent: selectorFallbackRecommendedIntent(reason),
119
+ };
120
+ }
121
+ function isSharedTestFixturePath(normalized) {
122
+ return (normalized.startsWith('tests/fixtures/') ||
123
+ normalized.startsWith('test/fixtures/') ||
124
+ normalized.startsWith('fixtures/') ||
125
+ /(?:^|\/)(?:test-)?setup\.[cm]?[jt]s$/u.test(normalized) ||
126
+ /(?:^|\/)(?:global-)?setup\.[cm]?[jt]s$/u.test(normalized));
127
+ }
128
+ function isMigrationOrDatabasePath(normalized) {
129
+ const basename = path.posix.basename(normalized).toLowerCase();
130
+ return (normalized.includes('/migrations/') ||
131
+ normalized.startsWith('migrations/') ||
132
+ normalized.includes('/db/') ||
133
+ normalized.includes('/database/') ||
134
+ basename === 'schema.prisma' ||
135
+ basename.endsWith('.sql'));
136
+ }
137
+ function isGeneratedContractPath(normalized) {
138
+ const basename = path.posix.basename(normalized).toLowerCase();
139
+ return (normalized.startsWith('schemas/') ||
140
+ basename.endsWith('.schema.json') ||
141
+ basename === 'openapi.json' ||
142
+ basename === 'openapi.yaml' ||
143
+ basename === 'openapi.yml' ||
144
+ basename === 'asyncapi.json' ||
145
+ basename === 'asyncapi.yaml' ||
146
+ basename === 'asyncapi.yml' ||
147
+ basename === 'schema.graphql');
148
+ }
149
+ function isCompilerOrRunnerConfigPath(normalized) {
150
+ const basename = path.posix.basename(normalized).toLowerCase();
151
+ return /^(?:tsconfig|eslint|vite|vitest|jest|playwright|astro|svelte|tailwind|webpack|rollup|babel|nyc|c8)\b/u.test(basename);
152
+ }
153
+ function selectorFallbackMessage(reason) {
154
+ switch (reason) {
155
+ case 'fallback_deleted':
156
+ return 'Deleted files can invalidate stale tests, public exports, and static dependency mappings.';
157
+ case 'fallback_renamed':
158
+ return 'Renamed files can leave stale import paths, test mappings, and documentation references behind.';
159
+ case 'fallback_type_changed':
160
+ return 'Type-changed files can change executable behavior in ways static test narrowing cannot classify.';
161
+ case 'fallback_lockfile':
162
+ return 'Lockfile changes can alter dependency resolution, toolchain behavior, and package artifacts.';
163
+ case 'fallback_package_metadata':
164
+ return 'Package metadata can change runtime entrypoints, scripts, dependencies, files, and release behavior.';
165
+ case 'fallback_template':
166
+ return 'Template changes can alter installed workflow contracts and downstream repository behavior.';
167
+ case 'fallback_generated_contract':
168
+ return 'Schema, OpenAPI, GraphQL, or generated-contract changes need contract and release-sensitive verification.';
169
+ case 'fallback_shared_test_fixture':
170
+ return 'Shared test fixtures or setup files can affect many tests outside the static source map.';
171
+ case 'fallback_migration_or_database':
172
+ return 'Migration, database, or SQL changes can alter shared test state and runtime contracts.';
173
+ case 'fallback_ci_workflow':
174
+ return 'CI workflow changes can alter job ordering, environment, caches, and selected-test safety.';
175
+ case 'fallback_mustflow_workflow':
176
+ return 'Mustflow workflow changes can alter command contracts, routing, skills, or verification policy.';
177
+ case 'fallback_compiler_or_runner_config':
178
+ return 'Compiler, bundler, lint, or test-runner config changes are unsafe for static test narrowing.';
179
+ case 'fallback_unknown':
180
+ return 'Unknown change surfaces need the configured fallback path instead of selected-test confidence.';
181
+ default:
182
+ return 'Changed files need the configured fallback path instead of selected-test confidence.';
183
+ }
184
+ }
185
+ function selectorFallbackRecommendedIntent(reason) {
186
+ switch (reason) {
187
+ case 'fallback_lockfile':
188
+ case 'fallback_package_metadata':
189
+ case 'fallback_template':
190
+ case 'fallback_generated_contract':
191
+ return 'test_release';
192
+ case 'fallback_migration_or_database':
193
+ case 'fallback_unknown':
194
+ return 'test';
195
+ default:
196
+ return 'test_related';
197
+ }
198
+ }
@@ -0,0 +1,443 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
5
+ export const LINK_INTEGRITY_PACK_ID = 'docs';
6
+ export const LINK_INTEGRITY_SCRIPT_ID = 'link-integrity';
7
+ export const LINK_INTEGRITY_SCRIPT_REF = `${LINK_INTEGRITY_PACK_ID}/${LINK_INTEGRITY_SCRIPT_ID}`;
8
+ const DEFAULT_MAX_FILES = 200;
9
+ const DEFAULT_MAX_FILE_BYTES = 512 * 1024;
10
+ const MAX_ISSUES = 50;
11
+ const DEFAULT_PATHS = ['README.md', 'schemas/README.md', 'docs-site/src/content/docs'];
12
+ const PATH_FILTERS = ['*.md', '*.mdx'];
13
+ const CHECKED_LINK_KINDS = ['local_file', 'local_anchor'];
14
+ const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', '.astro']);
15
+ const ERROR_CODES = new Set([
16
+ 'link_integrity_path_outside_root',
17
+ 'link_integrity_unreadable_path',
18
+ 'link_integrity_file_too_large',
19
+ 'link_integrity_max_files_exceeded',
20
+ ]);
21
+ function normalizeRelativePath(value) {
22
+ return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '') || '.';
23
+ }
24
+ function sha256Tagged(buffer) {
25
+ return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
26
+ }
27
+ function makeFinding(code, severity, pathValue, message, line) {
28
+ return line === undefined ? { code, severity, path: pathValue, message } : { code, severity, path: pathValue, line, message };
29
+ }
30
+ function pushIssue(issues, issue) {
31
+ if (issues.length < MAX_ISSUES) {
32
+ issues.push(issue);
33
+ }
34
+ }
35
+ function isDocumentPath(relativePath) {
36
+ return /\.(?:md|mdx)$/u.test(relativePath);
37
+ }
38
+ function normalizeInputPath(projectRoot, inputPath) {
39
+ const absolutePath = path.resolve(process.cwd(), inputPath);
40
+ ensureInside(projectRoot, absolutePath);
41
+ return {
42
+ absolutePath,
43
+ relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
44
+ };
45
+ }
46
+ function addCandidate(candidates, findings, issues, policy, candidate) {
47
+ if (candidates.has(candidate.relativePath)) {
48
+ return;
49
+ }
50
+ if (candidates.size >= policy.max_files) {
51
+ if (!findings.some((finding) => finding.code === 'link_integrity_max_files_exceeded')) {
52
+ const message = `Link-integrity matched more than ${policy.max_files} document files; remaining files were skipped.`;
53
+ pushIssue(issues, message);
54
+ findings.push(makeFinding('link_integrity_max_files_exceeded', 'medium', candidate.relativePath, message));
55
+ }
56
+ return;
57
+ }
58
+ candidates.set(candidate.relativePath, candidate);
59
+ }
60
+ function collectDocumentsFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
61
+ const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
62
+ if (IGNORED_DIRECTORIES.has(path.basename(relativeDirectory)) || [...IGNORED_DIRECTORIES].some((entry) => relativeDirectory.startsWith(`${entry}/`))) {
63
+ return;
64
+ }
65
+ let entries;
66
+ try {
67
+ ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
68
+ entries = readdirSync(absoluteDirectory, { withFileTypes: true });
69
+ }
70
+ catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ pushIssue(issues, `${relativeDirectory}: ${message}`);
73
+ findings.push(makeFinding('link_integrity_unreadable_path', 'high', relativeDirectory, message));
74
+ return;
75
+ }
76
+ for (const entry of entries) {
77
+ const absoluteEntry = path.join(absoluteDirectory, entry.name);
78
+ const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
79
+ if (entry.isDirectory()) {
80
+ collectDocumentsFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy);
81
+ continue;
82
+ }
83
+ if (entry.isFile() && isDocumentPath(relativeEntry)) {
84
+ addCandidate(candidates, findings, issues, policy, { absolutePath: absoluteEntry, relativePath: relativeEntry });
85
+ }
86
+ }
87
+ }
88
+ function collectDocumentCandidates(projectRoot, inputPaths, policy, findings, issues) {
89
+ const candidates = new Map();
90
+ const pathsToCheck = inputPaths.length > 0 ? inputPaths : policy.default_paths;
91
+ for (const inputPath of pathsToCheck) {
92
+ let absolutePath;
93
+ let relativePath;
94
+ try {
95
+ const normalized = normalizeInputPath(projectRoot, inputPath);
96
+ absolutePath = normalized.absolutePath;
97
+ relativePath = normalized.relativePath;
98
+ ensureInsideWithoutSymlinks(projectRoot, absolutePath, {
99
+ allowMissingDescendant: true,
100
+ allowMissingLeaf: true,
101
+ });
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ pushIssue(issues, message);
106
+ findings.push(makeFinding('link_integrity_path_outside_root', 'high', inputPath, message));
107
+ continue;
108
+ }
109
+ if (!existsSync(absolutePath)) {
110
+ continue;
111
+ }
112
+ let stats;
113
+ try {
114
+ stats = lstatSync(absolutePath);
115
+ }
116
+ catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ pushIssue(issues, `${relativePath}: ${message}`);
119
+ findings.push(makeFinding('link_integrity_unreadable_path', 'high', relativePath, message));
120
+ continue;
121
+ }
122
+ if (stats.isDirectory()) {
123
+ collectDocumentsFromDirectory(projectRoot, absolutePath, candidates, findings, issues, policy);
124
+ continue;
125
+ }
126
+ if (stats.isFile() && isDocumentPath(relativePath)) {
127
+ addCandidate(candidates, findings, issues, policy, { absolutePath, relativePath });
128
+ }
129
+ }
130
+ return [...candidates.values()].sort((left, right) => left.relativePath.localeCompare(right.relativePath));
131
+ }
132
+ function stripOptionalLinkTitle(target) {
133
+ return target.trim().replace(/\s+["'][^"']*["']\s*$/u, '').trim();
134
+ }
135
+ function normalizeInlineTarget(target) {
136
+ const stripped = stripOptionalLinkTitle(target);
137
+ if (stripped.startsWith('<') && stripped.endsWith('>')) {
138
+ return stripped.slice(1, -1).trim();
139
+ }
140
+ return stripped;
141
+ }
142
+ function extractRawLinks(relativePath, text) {
143
+ const links = [];
144
+ const lines = text.split(/\r?\n/u);
145
+ for (const [index, line] of lines.entries()) {
146
+ const lineNumber = index + 1;
147
+ for (const match of line.matchAll(/!?\[([^\]\n]*)\]\(([^)\n]+)\)/gu)) {
148
+ const target = normalizeInlineTarget(match[2] ?? '');
149
+ if (target.length === 0) {
150
+ continue;
151
+ }
152
+ links.push({
153
+ path: relativePath,
154
+ line: lineNumber,
155
+ text: match[1] ?? '',
156
+ target,
157
+ });
158
+ }
159
+ }
160
+ return links;
161
+ }
162
+ function decodeUriComponentSafe(value) {
163
+ try {
164
+ return decodeURIComponent(value);
165
+ }
166
+ catch {
167
+ return value;
168
+ }
169
+ }
170
+ function splitTarget(target) {
171
+ const [pathAndQuery = '', rawAnchor] = target.split('#', 2);
172
+ const pathPart = pathAndQuery.split('?', 1)[0] ?? '';
173
+ const anchor = rawAnchor === undefined ? null : decodeUriComponentSafe(rawAnchor);
174
+ return { pathPart, anchor };
175
+ }
176
+ function slugHeading(value) {
177
+ return value
178
+ .replace(/<[^>]+>/gu, '')
179
+ .replace(/[`*_~]/gu, '')
180
+ .replace(/\[([^\]]+)\]\([^)]+\)/gu, '$1')
181
+ .toLocaleLowerCase()
182
+ .replace(/[^\p{Letter}\p{Number}\s-]/gu, '')
183
+ .trim()
184
+ .replace(/\s+/gu, '-');
185
+ }
186
+ function collectAnchors(text) {
187
+ const anchors = new Set();
188
+ const counts = new Map();
189
+ for (const line of text.split(/\r?\n/u)) {
190
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/u.exec(line);
191
+ if (!match) {
192
+ continue;
193
+ }
194
+ const base = slugHeading(match[2] ?? '');
195
+ if (base.length === 0) {
196
+ continue;
197
+ }
198
+ const count = counts.get(base) ?? 0;
199
+ counts.set(base, count + 1);
200
+ anchors.add(count === 0 ? base : `${base}-${count}`);
201
+ }
202
+ return anchors;
203
+ }
204
+ function normalizeAnchorTarget(anchor) {
205
+ return slugHeading(anchor.replace(/^#+/u, ''));
206
+ }
207
+ function readDocumentText(projectRoot, absolutePath, maxBytes) {
208
+ return readFileInsideWithoutSymlinks(projectRoot, absolutePath, { maxBytes }).toString('utf8');
209
+ }
210
+ function validateLink(projectRoot, source, link, maxBytes) {
211
+ if (/^https?:\/\//iu.test(link.target)) {
212
+ return {
213
+ ...link,
214
+ kind: 'external_url',
215
+ resolved_path: null,
216
+ anchor: null,
217
+ status: 'skipped',
218
+ message: 'External URL is not fetched by this read-only offline check.',
219
+ };
220
+ }
221
+ if (/^mailto:/iu.test(link.target)) {
222
+ return {
223
+ ...link,
224
+ kind: 'email',
225
+ resolved_path: null,
226
+ anchor: null,
227
+ status: 'skipped',
228
+ message: 'Email link is outside local file and anchor validation.',
229
+ };
230
+ }
231
+ if (/^[a-z][a-z0-9+.-]*:/iu.test(link.target)) {
232
+ return {
233
+ ...link,
234
+ kind: 'external_url',
235
+ resolved_path: null,
236
+ anchor: null,
237
+ status: 'skipped',
238
+ message: 'Non-file URI scheme is outside local file and anchor validation.',
239
+ };
240
+ }
241
+ if (link.target.startsWith('/')) {
242
+ return {
243
+ ...link,
244
+ kind: 'site_path',
245
+ resolved_path: null,
246
+ anchor: null,
247
+ status: 'skipped',
248
+ message: 'Site-root path is not resolved against repository files.',
249
+ };
250
+ }
251
+ const { pathPart, anchor } = splitTarget(link.target);
252
+ const targetAbsolutePath = pathPart.length === 0
253
+ ? source.absolutePath
254
+ : path.resolve(path.dirname(source.absolutePath), decodeUriComponentSafe(pathPart));
255
+ try {
256
+ ensureInside(projectRoot, targetAbsolutePath);
257
+ }
258
+ catch {
259
+ return {
260
+ ...link,
261
+ kind: anchor === null ? 'local_file' : 'local_anchor',
262
+ resolved_path: null,
263
+ anchor,
264
+ status: 'missing',
265
+ message: `Linked path escapes the mustflow root: ${link.target}`,
266
+ };
267
+ }
268
+ const resolvedPath = normalizeRelativePath(path.relative(projectRoot, targetAbsolutePath));
269
+ if (!existsSync(targetAbsolutePath)) {
270
+ return {
271
+ ...link,
272
+ kind: anchor === null ? 'local_file' : 'local_anchor',
273
+ resolved_path: resolvedPath,
274
+ anchor,
275
+ status: 'missing',
276
+ message: `Linked file is missing: ${resolvedPath}`,
277
+ };
278
+ }
279
+ if (anchor === null || anchor.length === 0) {
280
+ return {
281
+ ...link,
282
+ kind: 'local_file',
283
+ resolved_path: resolvedPath,
284
+ anchor: null,
285
+ status: 'ok',
286
+ message: `Linked file exists: ${resolvedPath}`,
287
+ };
288
+ }
289
+ if (!isDocumentPath(resolvedPath)) {
290
+ return {
291
+ ...link,
292
+ kind: 'local_anchor',
293
+ resolved_path: resolvedPath,
294
+ anchor,
295
+ status: 'unknown',
296
+ message: `Anchor target is not a Markdown document: ${resolvedPath}`,
297
+ };
298
+ }
299
+ const targetText = readDocumentText(projectRoot, targetAbsolutePath, maxBytes);
300
+ const normalizedAnchor = normalizeAnchorTarget(anchor);
301
+ const anchors = collectAnchors(targetText);
302
+ return anchors.has(normalizedAnchor)
303
+ ? {
304
+ ...link,
305
+ kind: 'local_anchor',
306
+ resolved_path: resolvedPath,
307
+ anchor,
308
+ status: 'ok',
309
+ message: `Linked anchor exists: ${resolvedPath}#${normalizedAnchor}`,
310
+ }
311
+ : {
312
+ ...link,
313
+ kind: 'local_anchor',
314
+ resolved_path: resolvedPath,
315
+ anchor,
316
+ status: 'missing',
317
+ message: `Linked anchor is missing: ${resolvedPath}#${normalizedAnchor}`,
318
+ };
319
+ }
320
+ function findingForLink(link) {
321
+ if (link.status !== 'missing') {
322
+ return null;
323
+ }
324
+ if (link.kind === 'local_anchor' && link.anchor !== null && link.message.includes('Linked anchor is missing')) {
325
+ return makeFinding('link_integrity_missing_anchor', 'medium', link.path, link.message, link.line);
326
+ }
327
+ return makeFinding('link_integrity_missing_file', 'medium', link.path, link.message, link.line);
328
+ }
329
+ function linkIntegrityStatus(findings) {
330
+ if (findings.some((finding) => ERROR_CODES.has(finding.code))) {
331
+ return 'error';
332
+ }
333
+ if (findings.some((finding) => ['medium', 'high', 'critical'].includes(finding.severity))) {
334
+ return 'failed';
335
+ }
336
+ return 'passed';
337
+ }
338
+ function summarizeLinks(files, links) {
339
+ const count = (status) => links.filter((link) => link.status === status).length;
340
+ return {
341
+ files_checked: files.length,
342
+ links_checked: links.length,
343
+ ok: count('ok'),
344
+ missing: count('missing'),
345
+ skipped: count('skipped'),
346
+ unknown: count('unknown'),
347
+ };
348
+ }
349
+ function createInputHash(policy, files, links, findings) {
350
+ return sha256Tagged(JSON.stringify({
351
+ policy,
352
+ files: files.map((file) => ({ path: file.path, sha256: file.sha256, link_count: file.link_count })),
353
+ links: links.map((link) => ({
354
+ kind: link.kind,
355
+ path: link.path,
356
+ line: link.line,
357
+ target: link.target,
358
+ resolved_path: link.resolved_path,
359
+ anchor: link.anchor,
360
+ status: link.status,
361
+ })),
362
+ findings: findings.map((finding) => ({ code: finding.code, path: finding.path, line: finding.line })),
363
+ }));
364
+ }
365
+ export function checkLinkIntegrity(projectRoot, options) {
366
+ const root = path.resolve(projectRoot);
367
+ const policy = {
368
+ max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
369
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
370
+ default_paths: [...DEFAULT_PATHS],
371
+ path_filters: [...PATH_FILTERS],
372
+ checked_link_kinds: [...CHECKED_LINK_KINDS],
373
+ };
374
+ const findings = [];
375
+ const issues = [];
376
+ const fileEntries = [];
377
+ const links = [];
378
+ const candidates = collectDocumentCandidates(root, options.paths, policy, findings, issues);
379
+ for (const candidate of candidates) {
380
+ let buffer = null;
381
+ try {
382
+ buffer = readFileInsideWithoutSymlinks(root, candidate.absolutePath, { maxBytes: policy.max_file_bytes });
383
+ }
384
+ catch (error) {
385
+ const message = error instanceof Error ? error.message : String(error);
386
+ const code = message.includes('exceeds maximum size')
387
+ ? 'link_integrity_file_too_large'
388
+ : 'link_integrity_unreadable_path';
389
+ pushIssue(issues, `${candidate.relativePath}: ${message}`);
390
+ findings.push(makeFinding(code, 'high', candidate.relativePath, message));
391
+ }
392
+ const text = buffer?.toString('utf8') ?? '';
393
+ const rawLinks = buffer ? extractRawLinks(candidate.relativePath, text) : [];
394
+ for (const rawLink of rawLinks) {
395
+ try {
396
+ const validatedLink = validateLink(root, candidate, rawLink, policy.max_file_bytes);
397
+ links.push(validatedLink);
398
+ const finding = findingForLink(validatedLink);
399
+ if (finding) {
400
+ findings.push(finding);
401
+ }
402
+ }
403
+ catch (error) {
404
+ const message = error instanceof Error ? error.message : String(error);
405
+ pushIssue(issues, `${candidate.relativePath}:${rawLink.line}: ${message}`);
406
+ findings.push(makeFinding('link_integrity_unreadable_path', 'high', candidate.relativePath, message, rawLink.line));
407
+ }
408
+ }
409
+ fileEntries.push({
410
+ kind: 'document',
411
+ path: candidate.relativePath,
412
+ sha256: buffer ? sha256Tagged(buffer) : null,
413
+ size_bytes: buffer?.byteLength ?? null,
414
+ line_count: buffer ? text.split(/\r?\n/u).length : null,
415
+ link_count: rawLinks.length,
416
+ });
417
+ }
418
+ const sortedFiles = fileEntries.sort((left, right) => left.path.localeCompare(right.path));
419
+ const sortedLinks = links.sort((left, right) => left.path.localeCompare(right.path) ||
420
+ left.line - right.line ||
421
+ left.kind.localeCompare(right.kind) ||
422
+ left.target.localeCompare(right.target));
423
+ const status = linkIntegrityStatus(findings);
424
+ const summary = summarizeLinks(sortedFiles, sortedLinks);
425
+ return {
426
+ schema_version: '1',
427
+ command: 'script-pack',
428
+ pack_id: LINK_INTEGRITY_PACK_ID,
429
+ script_id: LINK_INTEGRITY_SCRIPT_ID,
430
+ script_ref: LINK_INTEGRITY_SCRIPT_REF,
431
+ action: 'check',
432
+ status,
433
+ ok: status === 'passed',
434
+ mustflow_root: root,
435
+ policy,
436
+ input_hash: createInputHash(policy, sortedFiles, sortedLinks, findings),
437
+ files: sortedFiles,
438
+ links: sortedLinks,
439
+ summary,
440
+ findings,
441
+ issues,
442
+ };
443
+ }