monecromanci 0.0.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.
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shared helpers for the reusable monorepo build templates.
5
+ *
6
+ * Every build step imports from this module so logging, command execution and
7
+ * Azure DevOps integration behave identically across the pipeline. Commands are
8
+ * executed through the platform shell using full command strings, which avoids
9
+ * the Windows `.cmd` spawning quirks that affect `execFile` with argument arrays.
10
+ */
11
+
12
+ import { execSync } from 'node:child_process'
13
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import process from 'node:process'
15
+
16
+ /**
17
+ * Returns whether the host platform is Windows.
18
+ *
19
+ * @returns {boolean} Returns true when running on Windows.
20
+ */
21
+ export function isWindows () {
22
+ return process.platform === 'win32'
23
+ }
24
+
25
+ /**
26
+ * Returns whether the script is running inside an Azure DevOps pipeline.
27
+ *
28
+ * @returns {boolean} Returns true when the Azure agent variables are present.
29
+ */
30
+ export function isAzure () {
31
+ return String(process.env.TF_BUILD || '').toLowerCase() === 'true'
32
+ }
33
+
34
+ /**
35
+ * Returns whether the script is running inside a GitHub Actions workflow.
36
+ *
37
+ * @returns {boolean} Returns true when the GitHub Actions variables are present.
38
+ */
39
+ export function isGitHub () {
40
+ return String(process.env.GITHUB_ACTIONS || '').toLowerCase() === 'true'
41
+ }
42
+
43
+ /**
44
+ * Appends a line to the file named by a GitHub Actions environment variable.
45
+ *
46
+ * @param {string} environmentName The env var holding the target file path (e.g. GITHUB_ENV).
47
+ * @param {string} line The line to append (a trailing newline is added).
48
+ */
49
+ function appendGitHubLine (environmentName, line) {
50
+ const filePath = process.env[environmentName]
51
+ if (filePath) {
52
+ appendFileSync(filePath, `${line}\n`)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Logs an informational message.
58
+ *
59
+ * @param {string} message The message to log.
60
+ */
61
+ export function log (message) {
62
+ console.log(message)
63
+ }
64
+
65
+ /**
66
+ * Logs a warning message.
67
+ *
68
+ * @param {string} message The message to log.
69
+ */
70
+ export function warn (message) {
71
+ console.warn(`[warning] ${message}`)
72
+ }
73
+
74
+ /**
75
+ * Logs an error message.
76
+ *
77
+ * @param {string} message The message to log.
78
+ */
79
+ export function error (message) {
80
+ console.error(`[error] ${message}`)
81
+ }
82
+
83
+ /**
84
+ * Logs a prominent banner used to separate pipeline phases.
85
+ *
86
+ * @param {string} message The banner text.
87
+ */
88
+ export function banner (message) {
89
+ const line = '='.repeat(72)
90
+
91
+ console.log(`\n${line}\n ${message}\n${line}`)
92
+ }
93
+
94
+ /**
95
+ * Logs a sub-section heading within a phase.
96
+ *
97
+ * @param {string} message The section text.
98
+ */
99
+ export function section (message) {
100
+ console.log(`\n--- ${message} ---`)
101
+ }
102
+
103
+ /**
104
+ * Logs an object array as a console table.
105
+ *
106
+ * @param {unknown} data The tabular data to render.
107
+ */
108
+ export function table (data) {
109
+ console.table(data)
110
+ }
111
+
112
+ /**
113
+ * Escapes a value for safe interpolation into a shell command string.
114
+ *
115
+ * @param {string} value The raw value to escape.
116
+ * @returns {string} Returns the quoted, shell-safe value.
117
+ */
118
+ export function shellEscape (value) {
119
+ const stringValue = String(value ?? '')
120
+
121
+ if (isWindows()) {
122
+ return `"${stringValue.replaceAll('"', '""')}"`
123
+ }
124
+
125
+ return `'${stringValue.replaceAll("'", "'\\''")}'`
126
+ }
127
+
128
+ /**
129
+ * Executes a command string and returns trimmed stdout.
130
+ *
131
+ * @param {string} command The full command line to execute.
132
+ * @param {import('node:child_process').ExecSyncOptions} [options] Optional exec options.
133
+ * @returns {string} Returns trimmed command output.
134
+ */
135
+ export function run (command, options = {}) {
136
+ const output = execSync(command, {
137
+ encoding: 'utf8',
138
+ stdio: ['ignore', 'pipe', 'pipe'],
139
+ ...options,
140
+ })
141
+
142
+ return output == null ? '' : output.toString().trim()
143
+ }
144
+
145
+ /**
146
+ * Executes a command string, returning an empty string on failure.
147
+ *
148
+ * @param {string} command The full command line to execute.
149
+ * @param {import('node:child_process').ExecSyncOptions} [options] Optional exec options.
150
+ * @returns {string} Returns trimmed output or an empty string.
151
+ */
152
+ export function runSafe (command, options = {}) {
153
+ try {
154
+ return run(command, options)
155
+ } catch (caughtError) {
156
+ const stderr = caughtError?.stderr ? String(caughtError.stderr).trim() : ''
157
+ const status = caughtError?.status ?? 'unknown'
158
+ warn(`Command failed (exit ${status}): ${command}`)
159
+
160
+ if (stderr) {
161
+ warn(`stderr: ${stderr}`)
162
+ }
163
+
164
+ return ''
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Executes a command string while inheriting stdio so output streams live.
170
+ *
171
+ * @param {string} command The full command line to execute.
172
+ * @param {import('node:child_process').ExecSyncOptions} [options] Optional exec options.
173
+ */
174
+ export function runInherit (command, options = {}) {
175
+ log(`$ ${command}`)
176
+ execSync(command, {
177
+ stdio: 'inherit',
178
+ ...options,
179
+ })
180
+ }
181
+
182
+ /**
183
+ * Reads and parses a JSON file from disk.
184
+ *
185
+ * @param {string} filePath The JSON file path.
186
+ * @returns {Record<string, any>} Returns parsed content.
187
+ */
188
+ export function readJson (filePath) {
189
+ return JSON.parse(readFileSync(filePath, 'utf8'))
190
+ }
191
+
192
+ /**
193
+ * Reads and parses a JSON file, returning a fallback on any failure.
194
+ *
195
+ * @param {string} filePath The JSON file path.
196
+ * @param {Record<string, any>} [fallback] The fallback value.
197
+ * @returns {Record<string, any>} Returns parsed content or the fallback.
198
+ */
199
+ export function readJsonSafe (filePath, fallback = {}) {
200
+ if (!existsSync(filePath)) {
201
+ return fallback
202
+ }
203
+
204
+ try {
205
+ return readJson(filePath)
206
+ } catch {
207
+ return fallback
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Writes JSON content with stable two-space formatting and a trailing newline.
213
+ *
214
+ * @param {string} filePath The destination file path.
215
+ * @param {unknown} content The serialisable content.
216
+ */
217
+ export function writeJson (filePath, content) {
218
+ writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`, 'utf8')
219
+ }
220
+
221
+ /**
222
+ * Emits an Azure DevOps pipeline variable, available to later steps.
223
+ *
224
+ * @param {string} name The variable name.
225
+ * @param {string | number | boolean} value The variable value.
226
+ */
227
+ export function setVariable (name, value) {
228
+ const stringValue = String(value ?? '')
229
+
230
+ if (isGitHub()) {
231
+ // Persist to later steps in the same job; mirrors Azure's setvariable.
232
+ appendGitHubLine('GITHUB_ENV', `${name}=${stringValue}`)
233
+ } else {
234
+ process.stdout.write(`##vso[task.setvariable variable=${name}]${stringValue}\n`)
235
+ }
236
+
237
+ process.env[name] = stringValue
238
+ }
239
+
240
+ /**
241
+ * Emits multiple Azure DevOps pipeline variables and logs them as a table.
242
+ *
243
+ * @param {{name: string, value: string}[]} variables The variables to emit.
244
+ */
245
+ export function setVariables (variables) {
246
+ table(variables.map(variable => ({ name: variable.name, value: variable.value })))
247
+
248
+ for (const variable of variables) {
249
+ setVariable(variable.name, variable.value)
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Sets the Azure DevOps build number.
255
+ *
256
+ * @param {string} buildNumber The build number to set.
257
+ */
258
+ export function setBuildNumber (buildNumber) {
259
+ if (isGitHub()) {
260
+ appendGitHubLine('GITHUB_OUTPUT', `build-number=${buildNumber}`)
261
+ return
262
+ }
263
+
264
+ process.stdout.write(`##vso[build.updatebuildnumber]${buildNumber}\n`)
265
+ }
266
+
267
+ /**
268
+ * Adds a build tag to the current Azure DevOps run.
269
+ *
270
+ * @param {string} tag The build tag to add.
271
+ */
272
+ export function addBuildTag (tag) {
273
+ if (isGitHub()) {
274
+ // No build-tag concept on GitHub; surface as an annotation + a step output a
275
+ // downstream deploy job/workflow can react to. Drops are the real handoff.
276
+ process.stdout.write(`::notice title=build-tag::${tag}\n`)
277
+ appendGitHubLine('GITHUB_OUTPUT', `build-tag-${tag.replaceAll(/[^a-zA-Z0-9_-]+/g, '-')}=true`)
278
+ return
279
+ }
280
+
281
+ process.stdout.write(`##vso[build.addbuildtag]${tag}\n`)
282
+ }
283
+
284
+ /**
285
+ * Uploads a markdown file as the build summary in Azure DevOps.
286
+ *
287
+ * @param {string} summaryPath The summary file path.
288
+ */
289
+ export function uploadSummary (summaryPath) {
290
+ if (isGitHub()) {
291
+ const stepSummary = process.env.GITHUB_STEP_SUMMARY
292
+ if (stepSummary && existsSync(summaryPath)) {
293
+ appendFileSync(stepSummary, `${readFileSync(summaryPath, 'utf8')}\n`)
294
+ }
295
+ return
296
+ }
297
+
298
+ process.stdout.write(`##vso[task.uploadsummary]${summaryPath}\n`)
299
+ }
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Monorepo context model: project classification, the persisted context file,
5
+ * and the filter helpers every build step uses to decide what to act on.
6
+ *
7
+ * Projects are classified purely from their Nx tags. A single canonical tag
8
+ * (`type:function-app`, `type:react-app`, `type:publishable-lib`,
9
+ * `type:internal-lib`, `ci:ignore`) is recommended, but legacy descriptive tags
10
+ * are still recognised through the alias table so existing repos keep working.
11
+ */
12
+
13
+ import path from 'node:path'
14
+ import process from 'node:process'
15
+ import { readJsonSafe } from './_h.mjs'
16
+
17
+ const WORKSPACE_ROOT = process.cwd()
18
+
19
+ /** Default location of the persisted context file. */
20
+ export const CONTEXT_FILE_PATH = path.join(WORKSPACE_ROOT, '.build-templates', '01-preparation.context.json')
21
+
22
+ /** Tag aliases mapping legacy and canonical tags onto pipeline categories. */
23
+ export const PROJECT_TAG_ALIASES = {
24
+ externalPackages: ['type:publishable-lib', 'external lib', 'external package', 'external', 'publishable lib', 'publishable package', 'publishable'],
25
+ functionApps: ['type:function-app', 'api', 'function', 'function app', 'function-app', 'backend', 'back-end'],
26
+ nodeApps: ['type:node-app', 'node', 'node app', 'node-app', 'service', 'server'],
27
+ ignore: ['ci:ignore', 'ignore'],
28
+ internalPackages: ['type:internal-lib', 'internal lib', 'internal package', 'internal'],
29
+ // Frontend multi-env apps (Vite-built React/Vue/Svelte and Next.js) all build
30
+ // dev/uat/prod outputs and are packaged identically (zip each dist-* directory).
31
+ reactApps: ['type:react-app', 'type:vue-app', 'type:svelte-app', 'type:nextjs-app', 'react app', 'react', 'vue', 'svelte', 'next', 'nextjs', 'frontend', 'front-end', 'app'],
32
+ }
33
+
34
+ /**
35
+ * Normalises a tag for case-insensitive comparison.
36
+ *
37
+ * @param {string} tag The raw tag value.
38
+ * @returns {string} Returns the normalised tag.
39
+ */
40
+ function normalizeTag (tag) {
41
+ return String(tag || '').trim().toLowerCase()
42
+ }
43
+
44
+ /**
45
+ * Returns whether any project tag matches one of the supplied aliases.
46
+ *
47
+ * @param {string[]} tags The project tags.
48
+ * @param {string[]} aliases The accepted aliases.
49
+ * @returns {boolean} Returns true when a match exists.
50
+ */
51
+ function hasTagAlias (tags, aliases) {
52
+ const normalizedTags = new Set(tags.map(normalizeTag))
53
+
54
+ return aliases.some(alias => normalizedTags.has(normalizeTag(alias)))
55
+ }
56
+
57
+ /**
58
+ * Sanitises a project name into an Azure variable token.
59
+ *
60
+ * @param {string} projectName The raw project name.
61
+ * @returns {string} Returns the sanitised token.
62
+ */
63
+ export function sanitizeVariableToken (projectName) {
64
+ return String(projectName || '')
65
+ .trim()
66
+ .replace(/[^A-Za-z0-9]+/g, '_')
67
+ .replace(/^_+|_+$/g, '')
68
+ .toUpperCase()
69
+ }
70
+
71
+ /**
72
+ * Resolves a field from a project's package manifest.
73
+ *
74
+ * @param {string} projectRoot The project root path.
75
+ * @param {string} field The package.json field to read.
76
+ * @returns {any} Returns the field value or undefined.
77
+ */
78
+ function readPackageField (projectRoot, field) {
79
+ const packageJsonPath = path.join(WORKSPACE_ROOT, projectRoot, 'package.json')
80
+
81
+ return readJsonSafe(packageJsonPath)[field]
82
+ }
83
+
84
+ /**
85
+ * Resolves the version declared in a project's package manifest.
86
+ *
87
+ * @param {string} projectRoot The project root path.
88
+ * @returns {string} Returns the version or an empty string.
89
+ */
90
+ export function resolveProjectVersion (projectRoot) {
91
+ const version = readPackageField(projectRoot, 'version')
92
+
93
+ return typeof version === 'string' ? version : ''
94
+ }
95
+
96
+ /**
97
+ * Resolves the package name declared by a project manifest.
98
+ *
99
+ * @param {string} projectRoot The project root path.
100
+ * @returns {string} Returns the package name or an empty string.
101
+ */
102
+ export function resolveProjectPackageName (projectRoot) {
103
+ const name = readPackageField(projectRoot, 'name')
104
+
105
+ return typeof name === 'string' ? name : ''
106
+ }
107
+
108
+ /**
109
+ * Resolves npm scripts from a project's package manifest.
110
+ *
111
+ * @param {string} projectRoot The project root path.
112
+ * @returns {Record<string, string>} Returns the project scripts.
113
+ */
114
+ function resolveProjectScripts (projectRoot) {
115
+ const scripts = readPackageField(projectRoot, 'scripts')
116
+
117
+ return typeof scripts === 'object' && scripts !== null ? scripts : {}
118
+ }
119
+
120
+ /**
121
+ * Resolves the build output directories declared by an Nx project.
122
+ *
123
+ * @param {Record<string, any>} project The project metadata.
124
+ * @returns {string[]} Returns output directories relative to the project root.
125
+ */
126
+ export function resolveBuildOutputDirectories (project) {
127
+ const outputs = Array.isArray(project?.targets?.build?.outputs)
128
+ ? project.targets.build.outputs
129
+ : []
130
+ const projectRootToken = '{projectRoot}/'
131
+
132
+ return outputs
133
+ .map(output => String(output || ''))
134
+ .filter(Boolean)
135
+ .map(output => output.startsWith(projectRootToken) ? output.slice(projectRootToken.length) : output)
136
+ .map(output => output.replace(/^\//, ''))
137
+ .filter(Boolean)
138
+ }
139
+
140
+ /**
141
+ * Resolves the Nx target names declared by a project.
142
+ *
143
+ * @param {Record<string, any>} project The project metadata.
144
+ * @returns {string[]} Returns target names.
145
+ */
146
+ function resolveTargetNames (project) {
147
+ if (!project || typeof project.targets !== 'object' || project.targets === null) {
148
+ return []
149
+ }
150
+
151
+ return Object.keys(project.targets)
152
+ }
153
+
154
+ /**
155
+ * Resolves the branch-aware React build plan for a frontend app.
156
+ *
157
+ * The resolved `distDirs` are best-effort hints; the packaging step zips
158
+ * whichever configured outputs actually exist after the build.
159
+ *
160
+ * @param {string} branchName The effective branch name.
161
+ * @param {Record<string, any>} project The project metadata.
162
+ * @returns {{command: string, distDirs: string[]}} Returns the build plan.
163
+ */
164
+ export function resolveReactBuildPlan (branchName, project) {
165
+ const scripts = resolveProjectScripts(project.root)
166
+ const outputDirectories = resolveBuildOutputDirectories(project)
167
+ const hasScript = scriptName => typeof scripts[scriptName] === 'string'
168
+ const filterOutputs = pattern => outputDirectories.filter(output => pattern.test(output))
169
+
170
+ if ((branchName === 'master' || branchName === 'main') && hasScript('build:all')) {
171
+ const distDirs = filterOutputs(/^dist-(dev|uat|prod)$/i)
172
+
173
+ return { command: 'build:all', distDirs: distDirs.length > 0 ? distDirs : outputDirectories }
174
+ }
175
+
176
+ if (branchName === 'uat' && hasScript('build:uat')) {
177
+ const distDirs = filterOutputs(/^dist-(dev|uat)$/i)
178
+
179
+ return { command: 'build:uat', distDirs: distDirs.length > 0 ? distDirs : ['dist'] }
180
+ }
181
+
182
+ if ((branchName === 'dev' || branchName === 'development') && hasScript('build:dev')) {
183
+ const distDirs = filterOutputs(/^dist-dev$/i)
184
+
185
+ return { command: 'build:dev', distDirs: distDirs.length > 0 ? distDirs : ['dist'] }
186
+ }
187
+
188
+ return {
189
+ command: hasScript('build') ? 'build' : 'build:local',
190
+ distDirs: outputDirectories.length > 0 ? outputDirectories : ['dist'],
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Classifies a project into pipeline categories from its tags.
196
+ *
197
+ * @param {Record<string, any>} project The project metadata.
198
+ * @returns {{ignored: boolean, tags: string[], type: Record<string, boolean>}} Returns classification data.
199
+ */
200
+ export function classifyProject (project) {
201
+ const tags = Array.isArray(project?.tags) ? project.tags.map(tag => String(tag)) : []
202
+ const ignored = hasTagAlias(tags, PROJECT_TAG_ALIASES.ignore)
203
+
204
+ return {
205
+ ignored,
206
+ tags,
207
+ type: {
208
+ externalPackage: !ignored && hasTagAlias(tags, PROJECT_TAG_ALIASES.externalPackages),
209
+ functionApp: !ignored && hasTagAlias(tags, PROJECT_TAG_ALIASES.functionApps),
210
+ nodeApp: !ignored && hasTagAlias(tags, PROJECT_TAG_ALIASES.nodeApps),
211
+ internalPackage: !ignored && hasTagAlias(tags, PROJECT_TAG_ALIASES.internalPackages),
212
+ reactApp: !ignored && hasTagAlias(tags, PROJECT_TAG_ALIASES.reactApps),
213
+ },
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Resolves a single human-readable label for a classified project.
219
+ *
220
+ * @param {Record<string, any>} project The enriched project data.
221
+ * @returns {string} Returns the project category label.
222
+ */
223
+ export function describeProjectType (project) {
224
+ if (project.ignored) {
225
+ return 'ignored'
226
+ }
227
+
228
+ if (project.type.functionApp) {
229
+ return 'function-app'
230
+ }
231
+
232
+ if (project.type.nodeApp) {
233
+ return 'node-app'
234
+ }
235
+
236
+ if (project.type.reactApp) {
237
+ return 'react-app'
238
+ }
239
+
240
+ if (project.type.externalPackage) {
241
+ return 'publishable-lib'
242
+ }
243
+
244
+ if (project.type.internalPackage) {
245
+ return 'internal-lib'
246
+ }
247
+
248
+ return 'unclassified'
249
+ }
250
+
251
+ /**
252
+ * Resolves the pipeline action a project will trigger when affected.
253
+ *
254
+ * @param {Record<string, any>} project The enriched project data.
255
+ * @returns {string} Returns the action label.
256
+ */
257
+ export function describeProjectAction (project) {
258
+ if (project.ignored) {
259
+ return 'skip'
260
+ }
261
+
262
+ if (project.type.functionApp || project.type.nodeApp || project.type.reactApp) {
263
+ return 'build + zip + drop'
264
+ }
265
+
266
+ if (project.type.externalPackage) {
267
+ return 'publish (version from package.json)'
268
+ }
269
+
270
+ if (project.type.internalPackage) {
271
+ return 'docs + vendored into apps'
272
+ }
273
+
274
+ return 'none'
275
+ }
276
+
277
+ /**
278
+ * Enriches raw Nx metadata into the pipeline project model.
279
+ *
280
+ * @param {Record<string, any>} metadata The Nx project metadata.
281
+ * @param {boolean} affected Whether the project is affected.
282
+ * @param {string} branchName The effective branch name.
283
+ * @returns {Record<string, any>} Returns the enriched project data.
284
+ */
285
+ export function enrichProject (metadata, affected, branchName) {
286
+ const classification = classifyProject(metadata)
287
+ const root = typeof metadata.root === 'string' ? metadata.root : ''
288
+
289
+ return {
290
+ affected,
291
+ buildOutputs: resolveBuildOutputDirectories(metadata),
292
+ ignored: classification.ignored,
293
+ name: typeof metadata.name === 'string' ? metadata.name : '',
294
+ packageName: root ? resolveProjectPackageName(root) : '',
295
+ projectType: typeof metadata.projectType === 'string' ? metadata.projectType : '',
296
+ reactBuild: classification.type.reactApp ? resolveReactBuildPlan(branchName, metadata) : null,
297
+ root,
298
+ sanitizedName: sanitizeVariableToken(metadata.name),
299
+ tags: classification.tags,
300
+ targetNames: resolveTargetNames(metadata),
301
+ type: classification.type,
302
+ version: root ? resolveProjectVersion(root) : '',
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Builds the persisted context manifest from enriched projects.
308
+ *
309
+ * @param {{baseCommit: string, branchName: string, headCommit: string, projects: Array<Record<string, any>>}} input The manifest source data.
310
+ * @returns {Record<string, any>} Returns the context manifest.
311
+ */
312
+ export function buildContextManifest (input) {
313
+ const { baseCommit, branchName, headCommit, projects } = input
314
+ const active = projects.filter(project => !project.ignored)
315
+ const affected = active.filter(project => project.affected)
316
+
317
+ return {
318
+ affectedProjects: affected.map(project => project.name),
319
+ baseCommit,
320
+ branchName,
321
+ generatedAt: new Date().toISOString(),
322
+ groups: {
323
+ externalPackages: affected.filter(project => project.type.externalPackage).map(project => project.name),
324
+ functionApps: affected.filter(project => project.type.functionApp).map(project => project.name),
325
+ nodeApps: affected.filter(project => project.type.nodeApp).map(project => project.name),
326
+ ignoredProjects: projects.filter(project => project.ignored).map(project => project.name),
327
+ internalPackages: affected.filter(project => project.type.internalPackage).map(project => project.name),
328
+ reactApps: affected.filter(project => project.type.reactApp).map(project => project.name),
329
+ },
330
+ hasAffected: affected.length > 0,
331
+ headCommit,
332
+ projects,
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Loads the persisted context manifest.
338
+ *
339
+ * @param {string} [contextPath] Optional explicit context file path.
340
+ * @returns {Record<string, any>} Returns the parsed context manifest.
341
+ */
342
+ export function loadContext (contextPath = process.env.MONOREPO_CONTEXT_FILE || CONTEXT_FILE_PATH) {
343
+ return readJsonSafe(contextPath, {})
344
+ }
345
+
346
+ /**
347
+ * Returns affected, non-ignored projects matching a category predicate.
348
+ *
349
+ * @param {Record<string, any>} context The context manifest.
350
+ * @param {(project: Record<string, any>) => boolean} predicate The category predicate.
351
+ * @returns {Array<Record<string, any>>} Returns the matching projects.
352
+ */
353
+ export function selectAffected (context, predicate) {
354
+ const projects = Array.isArray(context.projects) ? context.projects : []
355
+
356
+ return projects
357
+ .filter(project => project.affected && !project.ignored && predicate(project))
358
+ .sort((left, right) => String(left.name).localeCompare(String(right.name)))
359
+ }