arkgate 2.11.0 → 2.12.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,386 @@
1
+ /**
2
+ * Package-manager commands, agent instruction text, and CI workflow templates.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {
7
+ arkCommand,
8
+ detectPackageManager,
9
+ execCommandParts,
10
+ execRunner,
11
+ DEFAULT_INTENT_PREFIXES,
12
+ DEFAULT_LAYER_DIRECTORIES,
13
+ } from '../ark-shared.mjs';
14
+ import { falseGreenAdoptionGap } from './field-install.mjs';
15
+ import { PREFERRED_MCP_BIN } from './hook-templates.mjs';
16
+ import { readPackageJson } from './gate-files.mjs';
17
+
18
+ // Field-install helpers re-exported for callers that import from this module.
19
+ export {
20
+ ensureBaselineFlagInCheckCommand,
21
+ syncBaselineIntoCheckSurfaces,
22
+ pinArkgateDevDependency,
23
+ IO_DIR_SEGMENTS,
24
+ detectContractFalseGreenRisk,
25
+ FALSE_GREEN_GAP_ID,
26
+ falseGreenAdoptionGap,
27
+ } from './field-install.mjs';
28
+
29
+ export function checkArgsForRoot(root, { requireGates = false } = {}) {
30
+ const baselineFlag = fs.existsSync(path.join(root, '.ark-baseline.json'))
31
+ ? ' --baseline .ark-baseline.json'
32
+ : '';
33
+ const profile = requireGates ? '--strict' : '--strict-config';
34
+ return `--root . --config ark.config.json ${profile}${baselineFlag}`;
35
+ }
36
+
37
+
38
+ export function packageManager(root) {
39
+ // CI always require-gates; baseline follows checkArgsForRoot.
40
+ const checkArgs = checkArgsForRoot(root, { requireGates: true });
41
+ // Same detection as every emitted command (execRunner): honors the packageManager field and
42
+ // won't let a stray pnpm-lock.yaml hijack an npm project (package-lock.json wins the tie).
43
+ const pm = detectPackageManager(root);
44
+ if (pm === 'pnpm') {
45
+ return {
46
+ cache: 'pnpm',
47
+ setup: ['corepack enable'],
48
+ install: 'pnpm install --frozen-lockfile',
49
+ // Same runner as execRunner(): skip pnpm's verify-deps gate (ERR_PNPM_IGNORED_BUILDS).
50
+ run: `pnpm --config.verify-deps-before-run=false exec ark-check ${checkArgs}`,
51
+ };
52
+ }
53
+ if (pm === 'yarn') {
54
+ return {
55
+ cache: 'yarn',
56
+ setup: ['corepack enable'],
57
+ install: 'yarn install --frozen-lockfile',
58
+ run: `yarn ark-check ${checkArgs}`,
59
+ };
60
+ }
61
+ // Monorepo hosts (e.g. Next app under frontend/) often have a root package.json only for
62
+ // arkgate while real app deps live in frontend/package.json. Install both so CI can resolve
63
+ // the tree; ark-check itself only needs the root arkgate install.
64
+ const frontendPkg = fs.existsSync(path.join(root, 'frontend', 'package.json'));
65
+ const rootInstall = fs.existsSync(path.join(root, 'package-lock.json')) ? 'npm ci' : 'npm install';
66
+ const install = frontendPkg
67
+ ? `${rootInstall} && (cd frontend && ${fs.existsSync(path.join(root, 'frontend', 'package-lock.json')) ? 'npm ci' : 'npm install'})`
68
+ : rootInstall;
69
+ return {
70
+ cache: 'npm',
71
+ setup: [],
72
+ install,
73
+ run: `npx ark-check ${checkArgs}`,
74
+ };
75
+ }
76
+
77
+ // The runner prefix (npx / pnpm exec / yarn) is added per project by arkCheckCommand
78
+ // so a pnpm-only repo never gets an `npx` instruction — see execRunner() in ark-shared.mjs.
79
+ export function arkCheckCommand(root) {
80
+ return arkCommand(root, 'ark-check', checkArgsForRoot(root));
81
+ }
82
+
83
+ export function checkArchitectureScriptSnippet(root) {
84
+ // The package manager's runner resolves the installed binary; `node bin/ark-check.mjs`
85
+ // only works inside Ark's own repo. Package-manager aware so a pnpm/yarn repo isn't
86
+ // handed an `npx` alias that violates its "never npx" policy.
87
+ return `"check:architecture": "${arkCheckCommand(root)}"`;
88
+ }
89
+
90
+ // Canonical agent contract. AGENTS.md and the Cursor rule both derive from this single
91
+ // source so the steps can never drift out of sync between the two files. `steps(checkCommand)`
92
+ // is a builder because the check command's runner prefix varies with the package manager.
93
+ const AGENT_CONTRACT = {
94
+ manifestResource: 'ark://manifest',
95
+ steps: (checkCommand) => [
96
+ `Read the Ark contract from \`ark://manifest\` when the MCP server is available.`,
97
+ `Keep source files inside the layer boundaries declared in \`ark.config.json\`.`,
98
+ `Do not bypass Ark publishers, event contracts, or source metadata for runtime mutations.`,
99
+ `After edits, run \`${checkCommand}\`.`,
100
+ `If Ark reports violations, fix the architecture instead of weakening the gate.`,
101
+ ],
102
+ // Cursor-only guidance: the write-time validate_code tool is available in
103
+ // Cursor's runtime but has no equivalent in a plain AGENTS.md read.
104
+ cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
105
+ };
106
+
107
+ export function layerPlacementTable() {
108
+ const rows = DEFAULT_INTENT_PREFIXES.map((entry) => {
109
+ const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
110
+ .map((directory) => `\`${directory}/\``)
111
+ .join(', ');
112
+ return `| ${entry.layer} | ${dirs} | ${entry.prefixes.map((p) => `\`${p}\``).join(', ')} |`;
113
+ }).join('\n');
114
+ return `| Layer | Conventional directories (under the source root) | Intent prefixes |
115
+ |-------|---------------------------------------------------|-----------------|
116
+ ${rows}`;
117
+ }
118
+
119
+ export function agentInstructions(root) {
120
+ const checkCmd = arkCheckCommand(root);
121
+ const startCmd = arkCommand(root, 'ark', 'start');
122
+ const doctorCmd = arkCommand(root, 'ark-check', '--doctor');
123
+ const steps = AGENT_CONTRACT.steps(checkCmd)
124
+ .map((step, index) => `${index + 1}. ${step}`)
125
+ .join('\n');
126
+ return `# Ark Enforcement
127
+
128
+ ## Default agent flow (if unsure, do only this)
129
+
130
+ 1. If \`ark.config.json\` is missing: run \`${startCmd}\` once.
131
+ 2. For adoption / cleanup / “make architecture sound”: run the **\`/ark-autopilot\`** skill
132
+ (explore first → dual plan: remediation + pattern bets → safe fixes → gates). Day-zero
133
+ origin is frozen by \`ark start\`/\`ark init\` (or autopilot if missing) **before** agent docs.
134
+ Do **not** invent a second architecture curriculum outside the routing table below — when a
135
+ trigger matches, use that skill; when unsure, stay on autopilot.
136
+ 3. Status anytime: \`${doctorCmd}\` (status light + next action — not a mode picker).
137
+ 4. After ordinary feature edits: run \`${checkCmd}\`. On violations → **\`/ark-fix\`** (or
138
+ \`/ark-place\` for new files, \`/ark-contract\` only if the contract itself is wrong).
139
+
140
+ Skills are **dual-engine**: deterministic CLI sensors + exploratory read of *this* repo — not JSON-only wrappers.
141
+ When a skill says **STOP — do not continue this skill as complete**, stop and invoke the named handoff skill.
142
+
143
+ ### Subagent fan-out
144
+ If the host supports **parallel subagents**, skills may ask you to fan out **read-only**
145
+ scouts (disjoint path scopes) and merge in the parent. If the host does **not**,
146
+ **fall back to sequential** — one cluster/step at a time. Never parallel-write the same
147
+ files; never weaken the gate via subagents.
148
+
149
+ ## Skill routing (triggers → skill)
150
+
151
+ | When | Invoke |
152
+ |------|--------|
153
+ | Unsure / make architecture sound | **/ark-autopilot** (default) |
154
+ | Need map / opportunities only (no apply) | \`/ark-explore\` |
155
+ | Greenfield shape / empty tree | \`/ark-architect\` |
156
+ | Brownfield / wrong contract / false-green | \`/ark-adopt\` then \`/ark-contract\` if globs wrong |
157
+ | Edit \`ark.config.json\` layers/rules/intents | \`/ark-contract\` |
158
+ | New file “where does this go?” | \`/ark-place\` |
159
+ | Gate violation on a change | \`/ark-fix\` |
160
+ | Drive plan to goal.met | \`/ark-loop\` |
161
+ | Deep coverage + ranked audit | \`/ark-coverage\` |
162
+ | Design trade-offs (no package LLM) | \`/ark-think\` |
163
+ | Explain / HTML report tour | \`/ark-explain\` |
164
+ | Bump arkgate + refresh hosts | \`/ark-upgrade\` |
165
+ | Optional runtime kernel migrate | \`/ark-runtime\` |
166
+
167
+ ## Before editing TypeScript or JavaScript source files
168
+
169
+ ${steps}
170
+
171
+ ## Where new code belongs
172
+
173
+ \`ark.config.json\` is authoritative for this project. When creating a NEW kind of code
174
+ that no existing layer covers (a saga, a background job, a read model, ...), use the
175
+ default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
176
+ an ungoverned location:
177
+
178
+ ${layerPlacementTable()}
179
+
180
+ The project is only considered Ark-enforced when the write gate and CI gate pass
181
+ (runtime path only if this project opted into the kernel).
182
+ `;
183
+ }
184
+
185
+ export function mcpJson(root) {
186
+ return `${JSON.stringify({
187
+ mcpServers: {
188
+ ark: {
189
+ type: 'stdio',
190
+ // Prefer arkgate-mcp; ark-mcp alias still works for one major.
191
+ ...execCommandParts(root, PREFERRED_MCP_BIN, ['--root', '.', '--config', 'ark.config.json']),
192
+ },
193
+ },
194
+ }, null, 2)}\n`;
195
+ }
196
+
197
+ // Sample for docs/ — `ark-check --install-agent-gates --tools codex` auto-merges the real
198
+ // block (with absolute paths) into ~/.codex/config.toml. This copy is a reference only, so
199
+ // it flags the two gotchas of hand-editing the global config: absolute paths (config.toml is
200
+ // loaded without the project as cwd) and the required restart.
201
+ export function codexTomlSnippet(root) {
202
+ const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
203
+ '--root',
204
+ '/absolute/path/to/project',
205
+ '--config',
206
+ '/absolute/path/to/project/ark.config.json',
207
+ ]);
208
+ const argsToml = args.map((value) => `"${value}"`).join(', ');
209
+ return `# Add to ~/.codex/config.toml (or $CODEX_HOME/config.toml), then RESTART Codex —
210
+ # it does not hot-load MCP servers. Use ABSOLUTE paths: config.toml is global, so
211
+ # "." would resolve against Codex's launch dir, not this project. Prefer:
212
+ # ark-check --install-agent-gates --tools codex (auto-merges the absolute paths)
213
+ [mcp_servers.ark]
214
+ command = "${command}"
215
+ args = [${argsToml}]
216
+ `;
217
+ }
218
+
219
+ /**
220
+ * Compact always-on rule for instruction-tier hosts (Windsurf, Cline, GitHub Copilot,
221
+ * Kiro, ...): agents that read a project rule file but have no MCP tools or hooks.
222
+ * Derived from the same AGENT_CONTRACT as AGENTS.md and the Cursor rule so the steps
223
+ * can never drift; points at AGENTS.md for the full placement table.
224
+ */
225
+ export function instructionRule(root) {
226
+ const steps = AGENT_CONTRACT.steps(arkCheckCommand(root))
227
+ .map((step, index) => `${index + 1}. ${step}`)
228
+ .join('\n');
229
+ return `# Ark architecture contract
230
+
231
+ This project's architecture is governed by Ark (\`ark.config.json\` is authoritative).
232
+ Before writing or editing TypeScript or JavaScript source files:
233
+
234
+ ${steps}
235
+
236
+ See \`AGENTS.md\` for the full contract and the layer placement table.
237
+ `;
238
+ }
239
+
240
+ export function cursorRule(root) {
241
+ return `---
242
+ description: Ark architecture contract
243
+ alwaysApply: true
244
+ ---
245
+
246
+ Before writing or editing TypeScript or JavaScript source files, read the
247
+ \`${AGENT_CONTRACT.manifestResource}\` resource from the \`ark\` MCP server when available.
248
+
249
+ ${AGENT_CONTRACT.cursorValidateStep} After edits, run:
250
+
251
+ \`\`\`bash
252
+ ${arkCheckCommand(root)}
253
+ \`\`\`
254
+
255
+ If Ark reports violations, fix the architecture instead of bypassing the gate.
256
+ `;
257
+ }
258
+
259
+ // Default CI Node when the project declares nothing. A current LTS, NOT the
260
+ // oldest supported: the npm-ci-lockfile-mismatch failure only happens when CI's
261
+ // npm is OLDER than the npm that wrote the lockfile, so defaulting high is safer.
262
+ // Bumped 20 → 22 → 24 as consumer lockfiles moved with newer local npm.
263
+ const DEFAULT_CI_NODE_VERSION = '24';
264
+
265
+ /**
266
+ * Read Node majors from sibling GitHub Actions workflows (not ark-check.yml).
267
+ * A stale generated ark gate must not pin us to an old default when the project's
268
+ * real CI already runs a newer Node (classic "CI green / Ark red" false gate).
269
+ * @param {string} root
270
+ * @returns {string | null} highest major found, or null
271
+ */
272
+ export function detectNodeMajorFromWorkflows(root) {
273
+ const dir = path.join(root, '.github', 'workflows');
274
+ if (!fs.existsSync(dir)) return null;
275
+ let entries;
276
+ try {
277
+ entries = fs.readdirSync(dir);
278
+ } catch {
279
+ return null;
280
+ }
281
+ const majors = [];
282
+ for (const name of entries) {
283
+ if (!/\.ya?ml$/i.test(name)) continue;
284
+ // Ignore our own template so regenerating does not re-read a stale 20/22 pin.
285
+ if (/^ark-check\.ya?ml$/i.test(name)) continue;
286
+ let text;
287
+ try {
288
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
289
+ } catch {
290
+ continue;
291
+ }
292
+ // node-version: '24' | "24" | 24 | 24.x (skip node-version-file: lines)
293
+ for (const match of text.matchAll(/(?:^|\n)\s*(?:- )?node-version:\s*['"]?(\d+)/g)) {
294
+ majors.push(Number(match[1]));
295
+ }
296
+ }
297
+ if (majors.length === 0) return null;
298
+ // Highest major: older CI npm is the class that fails against modern lockfiles.
299
+ return String(Math.max(...majors));
300
+ }
301
+
302
+ // Decide the Node the generated CI should use, preferring the project's own
303
+ // declaration so CI's npm matches the dev's (a mismatch makes `npm ci` fail with
304
+ // "missing from lock file" — a red gate unrelated to architecture). In order:
305
+ // 1. .nvmrc / .node-version → setup-node's node-version-file (exact, best)
306
+ // 2. package.json engines.node → its concrete major
307
+ // 3. sibling workflows' node-version (highest major; excludes ark-check.yml)
308
+ // 4. a current-LTS default
309
+ export function detectCiNode(root) {
310
+ for (const file of ['.nvmrc', '.node-version']) {
311
+ if (fs.existsSync(path.join(root, file))) return { kind: 'file', value: file };
312
+ }
313
+ const enginesNode = readPackageJson(root)?.engines?.node;
314
+ if (typeof enginesNode === 'string') {
315
+ const major = enginesNode.match(/\d+/)?.[0];
316
+ if (major) return { kind: 'version', value: major };
317
+ }
318
+ const fromWorkflows = detectNodeMajorFromWorkflows(root);
319
+ if (fromWorkflows) return { kind: 'version', value: fromWorkflows };
320
+ return { kind: 'default', value: DEFAULT_CI_NODE_VERSION };
321
+ }
322
+
323
+ /**
324
+ * @param {{ name: string, install: string, run: string, cache: string, setup: string[] }} pm
325
+ * @param {{ kind: string, value: string }} ciNode
326
+ * @param {{ hasLintScript?: boolean, hasTypecheckScript?: boolean }} [quality]
327
+ */
328
+ export function githubWorkflow(pm, ciNode, quality = {}) {
329
+ // pnpm/yarn setup (corepack enable) MUST run before actions/setup-node so the package
330
+ // manager is on PATH when setup-node's `cache: pnpm|yarn` tries to resolve the store —
331
+ // otherwise the cache step fails on a fresh runner ("Unable to locate executable file: pnpm").
332
+ const setupSteps = pm.setup.map((command) => ` - run: ${command}`).join('\n');
333
+ // node-version-file keeps CI locked to the dev's exact toolchain; an explicit
334
+ // version comes from engines.node; the default carries a hint for the mismatch
335
+ // symptom since we can't know which npm wrote the lockfile.
336
+ const nodeSetup =
337
+ ciNode.kind === 'file'
338
+ ? ` node-version-file: ${ciNode.value}`
339
+ : ciNode.kind === 'version'
340
+ ? ` node-version: '${ciNode.value}'`
341
+ : ` # If the install step fails with "missing from lock file" / lockfile out
342
+ # of sync, your local package manager is newer than this Node's — add a
343
+ # .nvmrc with your Node version so CI matches the dev environment.
344
+ node-version: '${ciNode.value}'`;
345
+ // When package.json already has lint/typecheck, emit CI steps so deploy-path
346
+ // honesty matches local scripts (Next/CRA often run these in production build).
347
+ const install = pm.install || '';
348
+ const runPrefix = install.startsWith('pnpm')
349
+ ? 'pnpm run'
350
+ : install.startsWith('yarn')
351
+ ? 'yarn'
352
+ : install.startsWith('bun')
353
+ ? 'bun run'
354
+ : 'npm run';
355
+ const qualityBlock = [
356
+ quality.hasTypecheckScript
357
+ ? ` - name: Typecheck\n run: ${runPrefix} typecheck`
358
+ : '',
359
+ quality.hasLintScript ? ` - name: Lint\n run: ${runPrefix} lint` : '',
360
+ ]
361
+ .filter(Boolean)
362
+ .join('\n');
363
+ return `name: Ark architecture gate
364
+
365
+ on:
366
+ pull_request:
367
+ push:
368
+ branches: [main, master]
369
+
370
+ jobs:
371
+ ark-check:
372
+ runs-on: ubuntu-latest
373
+ steps:
374
+ - name: Checkout
375
+ uses: actions/checkout@v4
376
+ ${setupSteps ? `${setupSteps}\n` : ''} - name: Setup Node
377
+ uses: actions/setup-node@v4
378
+ with:
379
+ ${nodeSetup}
380
+ cache: ${pm.cache}
381
+ - name: Install dependencies
382
+ run: ${pm.install}
383
+ ${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
384
+ run: ${pm.run}
385
+ `;
386
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Production deploy-path quality signals (install modularization).
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { readPackageJson, packageScriptsHaveTypecheck } from './gate-files.mjs';
7
+
8
+ /**
9
+ * Production deploy path quality (universal — any consumer repo).
10
+ * Detects when the production build host runs ESLint / typecheck as part of
11
+ * `build` (e.g. Next.js "Linting and checking validity of types") so failures
12
+ * surface first on Vercel/Netlify/etc. unless CI/pre-merge runs the same checks.
13
+ * Framework signals only (deps + scripts + config) — never project-specific.
14
+ *
15
+ * @returns {{
16
+ * embedsLintInBuild: boolean,
17
+ * embedsTypecheckInBuild: boolean,
18
+ * engines: string[],
19
+ * hasLintScript: boolean,
20
+ * hasTypecheckScript: boolean,
21
+ * ciRunsLint: boolean,
22
+ * ciRunsTypecheck: boolean,
23
+ * eslintIgnoreDuringBuilds: boolean,
24
+ * }}
25
+ */
26
+ export function detectDeployPathQuality(root) {
27
+ const pkg = readPackageJson(root) || {};
28
+ const deps = {
29
+ ...(pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {}),
30
+ ...(pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {}),
31
+ ...(pkg.peerDependencies && typeof pkg.peerDependencies === 'object' ? pkg.peerDependencies : {}),
32
+ };
33
+ const scripts =
34
+ pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
35
+ const buildScript = typeof scripts.build === 'string' ? scripts.build : '';
36
+
37
+ const engines = [];
38
+ // Next.js production build runs ESLint + typecheck by default (unless opted out).
39
+ if (deps.next || /\bnext\s+build\b/.test(buildScript)) engines.push('next');
40
+ // Nuxt 3+ can lint via modules; only flag when build clearly invokes nuxt build + eslint tooling present.
41
+ if ((deps.nuxt || deps['nuxt3'] || /\bnuxt\s+build\b/.test(buildScript)) && (deps.eslint || hasEslintConfig(root))) {
42
+ engines.push('nuxt');
43
+ }
44
+ // Create React App historically failed build on ESLint errors.
45
+ if (deps['react-scripts'] || /\breact-scripts\s+build\b/.test(buildScript)) engines.push('cra');
46
+
47
+ const eslintIgnoreDuringBuilds = engines.includes('next') && nextIgnoresEslintDuringBuilds(root);
48
+ const embedsLintInBuild = engines.length > 0 && !eslintIgnoreDuringBuilds;
49
+ // Next still typechecks during build even when eslint.ignoreDuringBuilds is true.
50
+ const embedsTypecheckInBuild = engines.includes('next') || engines.includes('nuxt');
51
+
52
+ const scriptHasLint = (s) =>
53
+ Boolean(
54
+ s &&
55
+ ((typeof s.lint === 'string' && s.lint.trim()) ||
56
+ (typeof s.eslint === 'string' && s.eslint.trim()) ||
57
+ (typeof s['lint:ci'] === 'string' && s['lint:ci'].trim()) ||
58
+ (typeof s['check:lint'] === 'string' && s['check:lint'].trim()))
59
+ );
60
+
61
+ let hasLintScript = scriptHasLint(scripts);
62
+ let hasTypecheckScript = packageScriptsHaveTypecheck(scripts);
63
+ const packageLintScripts = [];
64
+ // Monorepo: package-level scripts count (apps/web, packages/ui, …).
65
+ try {
66
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
67
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
68
+ const candidates = [path.join(root, entry.name)];
69
+ // one more level: packages/foo
70
+ try {
71
+ for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
72
+ if (child.isDirectory() && !child.name.startsWith('.')) {
73
+ candidates.push(path.join(root, entry.name, child.name));
74
+ }
75
+ }
76
+ } catch {
77
+ /* ignore */
78
+ }
79
+ for (const dir of candidates) {
80
+ const pj = path.join(dir, 'package.json');
81
+ if (!fs.existsSync(pj)) continue;
82
+ try {
83
+ const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
84
+ const ns = nested.scripts && typeof nested.scripts === 'object' ? nested.scripts : {};
85
+ if (scriptHasLint(ns)) {
86
+ hasLintScript = true;
87
+ packageLintScripts.push(path.relative(root, dir).split(path.sep).join('/'));
88
+ }
89
+ if (packageScriptsHaveTypecheck(ns)) hasTypecheckScript = true;
90
+ const nd = {
91
+ ...(nested.dependencies || {}),
92
+ ...(nested.devDependencies || {}),
93
+ };
94
+ if (nd.next && !engines.includes('next')) engines.push('next');
95
+ } catch {
96
+ /* ignore */
97
+ }
98
+ }
99
+ }
100
+ } catch {
101
+ /* ignore */
102
+ }
103
+
104
+ const ciTexts = collectCiWorkflowTexts(root);
105
+ const ciJoined = ciTexts.join('\n');
106
+ const ciRunsLint =
107
+ ciTexts.length > 0 &&
108
+ (/\bnpm\s+run\s+lint\b/i.test(ciJoined) ||
109
+ /\bpnpm\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
110
+ /\byarn\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
111
+ /\bbun\s+run\s+lint\b/i.test(ciJoined) ||
112
+ /\beslint\b/i.test(ciJoined) ||
113
+ /\blint:ci\b/i.test(ciJoined) ||
114
+ /\bcheck:lint\b/i.test(ciJoined) ||
115
+ // package-level: working-directory + lint, or path/filter lint
116
+ (packageLintScripts.length > 0 &&
117
+ packageLintScripts.some((p) => ciJoined.includes(p) && /lint/i.test(ciJoined))));
118
+ const ciRunsTypecheck =
119
+ ciTexts.length > 0 &&
120
+ (/\btypecheck\b/i.test(ciJoined) ||
121
+ /\btype-check\b/i.test(ciJoined) ||
122
+ /\bcheck:types\b/i.test(ciJoined) ||
123
+ /\btsc\s+--noEmit\b/i.test(ciJoined));
124
+
125
+ return {
126
+ embedsLintInBuild,
127
+ embedsTypecheckInBuild,
128
+ engines,
129
+ hasLintScript,
130
+ hasTypecheckScript,
131
+ ciRunsLint,
132
+ ciRunsTypecheck,
133
+ eslintIgnoreDuringBuilds,
134
+ hasCiWorkflows: ciTexts.length > 0,
135
+ packageLintScripts,
136
+ };
137
+ }
138
+
139
+ function hasEslintConfig(root) {
140
+ return [
141
+ 'eslint.config.mjs',
142
+ 'eslint.config.js',
143
+ 'eslint.config.cjs',
144
+ 'eslint.config.ts',
145
+ '.eslintrc.json',
146
+ '.eslintrc.cjs',
147
+ '.eslintrc.js',
148
+ '.eslintrc.yml',
149
+ '.eslintrc.yaml',
150
+ ].some((f) => fs.existsSync(path.join(root, f)));
151
+ }
152
+
153
+ /** next.config.* eslint.ignoreDuringBuilds: true → production build will not fail on ESLint. */
154
+ function nextIgnoresEslintDuringBuilds(root) {
155
+ const names = [
156
+ 'next.config.ts',
157
+ 'next.config.mts',
158
+ 'next.config.js',
159
+ 'next.config.mjs',
160
+ 'next.config.cjs',
161
+ ];
162
+ for (const name of names) {
163
+ const file = path.join(root, name);
164
+ if (!fs.existsSync(file)) continue;
165
+ try {
166
+ const text = fs.readFileSync(file, 'utf8');
167
+ // Common patterns: ignoreDuringBuilds: true | ignoreDuringBuilds: true,
168
+ if (/ignoreDuringBuilds\s*:\s*true/.test(text)) return true;
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+ return false;
174
+ }
175
+
176
+ function collectCiWorkflowTexts(root) {
177
+ const texts = [];
178
+ const pushFile = (rel) => {
179
+ try {
180
+ const full = path.join(root, rel);
181
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
182
+ texts.push(fs.readFileSync(full, 'utf8'));
183
+ }
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ };
188
+ pushFile('.gitlab-ci.yml');
189
+ pushFile('bitbucket-pipelines.yml');
190
+ pushFile('azure-pipelines.yml');
191
+ pushFile('.circleci/config.yml');
192
+ const wfDir = path.join(root, '.github', 'workflows');
193
+ try {
194
+ if (fs.existsSync(wfDir)) {
195
+ for (const f of fs.readdirSync(wfDir)) {
196
+ if (!/\.ya?ml$/i.test(f)) continue;
197
+ pushFile(path.join('.github', 'workflows', f));
198
+ }
199
+ }
200
+ } catch {
201
+ /* ignore */
202
+ }
203
+ return texts;
204
+ }
205
+