From edc0a5b859610864567b9f4811c605abf82c83cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:22:34 +0000 Subject: [PATCH] fix(detect): read package.json as one bounded fd, strip a BOM, skip non-JS trees (PHARN-15) Archetype detection read package.json with existsSync + readFileSync: a FIFO hung init, a symlink to /dev/zero read without bound, and a UTF-8 BOM made JSON.parse fail so a Next.js app detected as `lib`. It now opens once (O_NONBLOCK), requires a regular file, reads at most 4 MiB, and strips a leading BOM. SKIP_DIRS gains .venv, venv, __pycache__, vendor, target and .yarn so a large non-JS tree cannot exhaust the walk budget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TvcuVhk8hTeDskp5pAJhnc --- .dev/features/bounded-package-json/GRILL.md | 29 +++++++ .dev/features/bounded-package-json/PLAN.md | 54 +++++++++++++ .../bounded-package-json/REGRESSION.md | 30 +++++++ .dev/features/bounded-package-json/REVIEW.md | 38 +++++++++ .dev/features/bounded-package-json/SHIP.md | 17 ++++ .dev/features/bounded-package-json/VERIFY.md | 27 +++++++ .../regression-report.json | 22 +++++ .../bounded-package-json/verify-report.json | 17 ++++ .pharn/writes-scope.json | 4 +- docs/commands/init.md | 2 +- docs/troubleshooting.md | 2 +- src/lib/detect-archetype.ts | 76 +++++++++++++++-- tests/detect-archetype.test.ts | 81 +++++++++++++++++++ 13 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 .dev/features/bounded-package-json/GRILL.md create mode 100644 .dev/features/bounded-package-json/PLAN.md create mode 100644 .dev/features/bounded-package-json/REGRESSION.md create mode 100644 .dev/features/bounded-package-json/REVIEW.md create mode 100644 .dev/features/bounded-package-json/SHIP.md create mode 100644 .dev/features/bounded-package-json/VERIFY.md create mode 100644 .dev/features/bounded-package-json/regression-report.json create mode 100644 .dev/features/bounded-package-json/verify-report.json diff --git a/.dev/features/bounded-package-json/GRILL.md b/.dev/features/bounded-package-json/GRILL.md new file mode 100644 index 00000000..e642f3dc --- /dev/null +++ b/.dev/features/bounded-package-json/GRILL.md @@ -0,0 +1,29 @@ +# GRILL — bounded-package-json + +Plan: `.dev/features/bounded-package-json/PLAN.md` · spec-hash `bca940a5…d729d3c4e` matches live +`ARCHITECTURE.md`. Registered grillers: `{"registered":0,"grillers":[]}` → inline axes only. + +## Findings + +```yaml +- type: FINDING + rule_id: 'P5' + severity: important + file: '.dev/features/bounded-package-json/PLAN.md:6' + problem: 'Every read failure (open error other than ENOENT, non-regular file, over the cap, read error) must keep today''s shape — `packageJsonFound: false`, never a throw — so init still proceeds on file-tree signals.' + evidence: '`larger → "not usable", like a parse error`' +- type: FINDING + rule_id: 'P5' + severity: minor + file: '.dev/features/bounded-package-json/PLAN.md:8' + problem: '`vendor`/`target` could hold hand-authored source in some ecosystems; the SKIP_DIRS comment already records that the failure direction is a LOST signal, never a false one — keep that tradeoff explicit for the new members.' + evidence: '`SKIP_DIRS gains ... vendor, target`' +- type: FINDING + rule_id: 'P1' + severity: minor + file: '.dev/features/bounded-package-json/PLAN.md:30' + problem: 'The FIFO eval must be skipped on win32 (mkfifo) and must bound its own runtime, so a regression fails the test instead of hanging the suite.' + evidence: '`FIFO → returns promptly, not found`' +``` + +ADVISORY VERDICT: 3 concerns raised (0 blocking-severity, 3 advisory) — all folded into the build. diff --git a/.dev/features/bounded-package-json/PLAN.md b/.dev/features/bounded-package-json/PLAN.md new file mode 100644 index 00000000..f62f9a45 --- /dev/null +++ b/.dev/features/bounded-package-json/PLAN.md @@ -0,0 +1,54 @@ +# PLAN — bounded-package-json (PHARN-15: archetype detection reads package.json safely and skips more heavy trees) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: `readPackageSignals` opens `package.json` ONCE (`O_NONBLOCK`, so a FIFO cannot hang the open), + checks `fstat().isFile()` on that descriptor, reads at most a fixed cap (larger → "not usable", like a + parse error), and strips a leading UTF-8 BOM before `JSON.parse`. `SKIP_DIRS` gains `.venv`, `venv`, + `vendor`, `target`, `.yarn`, `__pycache__`, so a large dependency/build tree cannot exhaust the walk + budget before it reaches the project's source. +- layer(s): the CLI itself (`src/lib/detect-archetype.ts`) +- constitution_refs: [P1, P2, P4, P5] + +## Discovery — verified this run (P6) + +Review repro: a BOM-prefixed Next.js `package.json` → `JSON.parse` throws → `packageJsonFound: false`, +detected `lib` instead of `ssr`; a FIFO at `package.json` hangs `init` (8 s timeout in the repro); a +symlink to `/dev/zero` reads without bound (~1.5 GB); a `.venv` with 50k files exhausts `MAX_ENTRIES` +before `src/` → `lib`. Code: `existsSync` + `readFileSync(pkgPath)` (`detect-archetype.ts:169-171`); +`SKIP_DIRS` at `:70`. The same open/fstat/read-from-fd pattern exists in `readCapabilityMarkdown` and +`readMinCli`. A symlinked `package.json` in the user's own project stays FOLLOWED (legitimate); the +`isFile` + size cap is what bounds it. + +## Files + +- `src/lib/detect-archetype.ts` — fd-based bounded read + BOM strip; six new `SKIP_DIRS` members — layer CLI/lib +- `tests/detect-archetype.test.ts` — BOM → `ssr` + found; FIFO → returns promptly, not found; oversized → + not found; a symlink to a regular file still read; `.venv` with a signal-free flood before `src/` still + detects the source signal (the existing uniform SKIP_DIRS pins cover the new members) +- `docs/commands/init.md` — the skip list (P4) +- `docs/troubleshooting.md` — the skip list (P4) + +## Contracts satisfied + +- `docs/commands/init.md` "bounded and symlink-safe" — now also true of the package.json read. + +## Evals to write (P1) + +- listed above; BOM, FIFO, oversized and `.venv` cases fail on the base source. + +## Guarantee audit (P0) + +- "detection never blocks on or reads without bound from package.json" → floor: non-blocking open + + descriptor type check + fixed-size buffer. + +## Trust audit (P2) + +- package.json content is still only JSON-parsed and reduced to dependency NAMES against allowlists. + +## Determinism audit (P5) + +- Same bytes → same result; a skipped dir is skipped by name, uniformly. + +## Open questions (HALT) + +- none diff --git a/.dev/features/bounded-package-json/REGRESSION.md b/.dev/features/bounded-package-json/REGRESSION.md new file mode 100644 index 00000000..43ea97b1 --- /dev/null +++ b/.dev/features/bounded-package-json/REGRESSION.md @@ -0,0 +1,30 @@ +# REGRESSION — bounded-package-json + +The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment. + +## Base and partition + +- **base:** `6b82dc93f057d2530f6a3321d2070fc89f8f2e37` (`origin/main` at build time; the build is an uncommitted working tree on top of it). +- **inside** (each declared in `PLAN.md` `## Files`): `docs/commands/init.md`, `docs/troubleshooting.md`, `src/lib/detect-archetype.ts`, `tests/detect-archetype.test.ts`. +- **scope partition:** `check-regress.mjs scope` exited **0**, `escaped: []`. `.pharn/` (hook scratch) and this + feature's own stage artifacts are not build output. +- **outside gates:** the stdlib `*.test.mjs` / `*.test.cjs` files + whole-repo `validate`; 0 committed eval pairs. +- **style-gate skip:** `inside` touches no shared style config, so `lint` / `format:check` / `lint:md` are absent from both maps. + +## Per-gate exit codes + +| gate | base | head | flipped? | +| ---------- | ---- | ---- | -------- | +| `tests` | 0 | 0 | no | +| `validate` | 0 | 0 | no | + +- `regressions[]`: **empty** +- `pre_existing[]`: **empty** + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** +(`regression-report.json` `.verdict` = `no-regressions`.) + +Residual (P0/P7): this catches exactly what its suite catches. The vitest suite exercising `src/**` is +owned by `/pharn-dev-build`'s floor and `/pharn-dev-verify`. This certifies the comparison, never the increment. diff --git a/.dev/features/bounded-package-json/REVIEW.md b/.dev/features/bounded-package-json/REVIEW.md new file mode 100644 index 00000000..0727b2d3 --- /dev/null +++ b/.dev/features/bounded-package-json/REVIEW.md @@ -0,0 +1,38 @@ +# REVIEW — bounded-package-json + +Floor first: `node .dev/floor/validate.mjs .` → exit 0 (GREEN). Everything below is **advisory**. + +## Floor-gate findings (blocking) + +None. + +- **L-floor (P0):** the open is non-blocking, the descriptor must be a regular file, and at most + `MAX_PACKAGE_JSON_BYTES + 1` bytes are ever allocated or read; every failure is `null` → not found + (grill #1), never a throw. +- **L-eval (P1):** BOM, over-cap and the six new skip members fail on the base source; the FIFO case + HANGS the base source (verified with an external `timeout`), which is the defect. The FIFO and + `/dev/zero` cases are skipped on win32 (grill #3). The uniform SKIP_DIRS pins iterate the shipped set + and so cannot catch a missing member — hence the explicit list. +- **L-trust (P2):** content is still only JSON-parsed and reduced to dependency names. +- **L-axis (P3):** contained in `detect-archetype.ts`. + +## Advisory findings + +```yaml +- type: FINDING + rule_id: 'P5' + severity: minor + file: 'src/lib/detect-archetype.ts:70' + problem: 'A project that keeps hand-authored source under `vendor/` or `target/` loses that file-tree signal (package.json still backstops it) — the documented LOST-signal tradeoff, now wider (grill #2).' + evidence: "'vendor', // Go / PHP (Composer) / Ruby vendored dependencies" +- type: FINDING + rule_id: 'P4' + severity: minor + file: 'CHANGELOG.md:8' + problem: "No CHANGELOG `[Unreleased]` entry (not in the plan's `## Files`)." + evidence: '## [Unreleased]' +``` + +## Verdict + +**GREEN** — 0 floor-gate findings, 2 advisory findings. No lesson proposed for canon. diff --git a/.dev/features/bounded-package-json/SHIP.md b/.dev/features/bounded-package-json/SHIP.md new file mode 100644 index 00000000..1fb8fb77 --- /dev/null +++ b/.dev/features/bounded-package-json/SHIP.md @@ -0,0 +1,17 @@ +# SHIP — bounded-package-json + +Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: **Approve as written**) → `/pharn-dev-grill` → +`/pharn-dev-build` → `/pharn-dev-regress` → `/pharn-dev-verify` → `/pharn-dev-review` → GATE 2. + +| stage | structural verdict (verbatim) | +| -------------------- | ------------------------------------------------------ | +| `/pharn-dev-build` | `node .dev/floor/validate.mjs .` exit `0` | +| `/pharn-dev-regress` | `regression-report.json` `.verdict` = `no-regressions` | +| `/pharn-dev-verify` | `verify-report.json` `.verdict` = `PASS` | + +- Review: [`REVIEW.md`](REVIEW.md) · Grill (advisory): [`GRILL.md`](GRILL.md) +- Run ended at **GATE 2**. The human's standing instruction for this batch: open a PR and merge it only if + its CI checks are green. + +chain ran; the named floor verdicts are as shown — this is NOT a judgment that the increment is good or +wise; that is the human's call at the post-review gate. diff --git a/.dev/features/bounded-package-json/VERIFY.md b/.dev/features/bounded-package-json/VERIFY.md new file mode 100644 index 00000000..7a66c925 --- /dev/null +++ b/.dev/features/bounded-package-json/VERIFY.md @@ -0,0 +1,27 @@ +# VERIFY — bounded-package-json + +## FLOOR layer (owns the verdict) + +Gates run over the whole repo with the feature present, as a non-root user on node 22 with the session +proxy variables unset (as root with the proxy set, 5 pre-existing tests in `init.test.ts` / +`update.test.ts` fail for environmental reasons, identically at the baseline). + +| gate | exit | +| -------------- | ---- | +| `format:check` | 0 | +| `lint` | 0 | +| `lint:md` | 0 | +| `test` | 0 | +| `typecheck` | 0 | +| `validate` | 0 | + +No `structural:*` gate — the increment ships no eval-actual pair. + +**VERDICT: PASS** (`.dev/floor/check-verify.mjs`, `failing_gates: []`). + +## ADVISORY layer + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}` — floor gates only. + +Residual (P0/P7): "verified" means the named gates passed — not that the feature is correct in any sense +the suite does not encode. diff --git a/.dev/features/bounded-package-json/regression-report.json b/.dev/features/bounded-package-json/regression-report.json new file mode 100644 index 00000000..47a22318 --- /dev/null +++ b/.dev/features/bounded-package-json/regression-report.json @@ -0,0 +1,22 @@ +{ + "base": "6b82dc93f057d2530f6a3321d2070fc89f8f2e37", + "inside": [ + "docs/commands/init.md", + "docs/troubleshooting.md", + "src/lib/detect-archetype.ts", + "tests/detect-archetype.test.ts" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/bounded-package-json/verify-report.json b/.dev/features/bounded-package-json/verify-report.json new file mode 100644 index 00000000..bf3d1d21 --- /dev/null +++ b/.dev/features/bounded-package-json/verify-report.json @@ -0,0 +1,17 @@ +{ + "feature": "bounded-package-json", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "typecheck": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { + "registered": 0, + "findings": [] + } +} diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index b769d55b..95d0f33f 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/strict-frontmatter-fence/SHIP.md" + ".dev/features/bounded-package-json/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-09-24T09:55:43.509Z" + "set_at": "2026-09-24T10:22:32.356Z" } diff --git a/docs/commands/init.md b/docs/commands/init.md index cb31b82e..0d83be73 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -104,7 +104,7 @@ Shows the PHARN logo and CLI version. ### 3. Detect archetypes -Reads `package.json` dependency names and walks the project tree (bounded and symlink-safe, skipping dependencies, VCS metadata, and build/deploy caches — `node_modules`, `.git`, `dist`, `build`, `out`, `coverage`, `storybook-static`, `.next`, `.nuxt`, `.svelte-kit`, `.astro`, `.turbo`, `.vercel`, `.cache`, `.parcel-cache`) for structural signals, then reduces both to an `Archetype[]`. Skipping those trees costs the walk nothing, so a large framework cache cannot exhaust its bound and hide your real source; the tradeoff is that a signal file you hand-authored inside one of those directories is not seen. The detected set is shown in a "Detected archetypes" note. Only names are tested against fixed in-code allowlists — no discovered file body is read (other than `package.json`) and no untrusted value is executed, interpolated, or logged. +Reads `package.json` dependency names and walks the project tree (bounded and symlink-safe, skipping dependencies, VCS metadata, and build/deploy caches — `node_modules`, `.git`, `dist`, `build`, `out`, `coverage`, `storybook-static`, `.next`, `.nuxt`, `.svelte-kit`, `.astro`, `.turbo`, `.vercel`, `.cache`, `.parcel-cache`, and non-JS dependency/build trees — `.venv`, `venv`, `__pycache__`, `vendor`, `target`, `.yarn`) for structural signals, then reduces both to an `Archetype[]`. Skipping those trees costs the walk nothing, so a large framework cache cannot exhaust its bound and hide your real source; the tradeoff is that a signal file you hand-authored inside one of those directories is not seen. The detected set is shown in a "Detected archetypes" note. Only names are tested against fixed in-code allowlists — no discovered file body is read (other than `package.json`, which must be a regular file of at most 4 MiB — a leading UTF-8 BOM is ignored; anything else counts as no `package.json`) and no untrusted value is executed, interpolated, or logged. ### 4. Fetch PHARN diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 02acb185..96616d83 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -134,7 +134,7 @@ installs the **universal** capabilities — the ones that apply to any codebase. ### Monorepos / workspaces -`pharn init` checks the **current directory** for a `.git` directory, reads the `package.json` there and runs a bounded, symlink-safe file-tree scan from it for archetype detection, then installs into that directory. The scan skips heavy or generated trees (`node_modules`, `dist`, `build`, `.next`, `out`, `coverage`, framework caches), so in a workspace it still sees `apps/` and `packages/`. It does not walk up to a workspace root or into workspace packages. In a monorepo, run it from the directory that contains both `.git` and the app's `package.json`. Split layouts (`.git` at the root, the app's `package.json` in `apps/web/`) are unsupported in v1. +`pharn init` checks the **current directory** for a `.git` directory, reads the `package.json` there and runs a bounded, symlink-safe file-tree scan from it for archetype detection, then installs into that directory. The scan skips heavy or generated trees (`node_modules`, `dist`, `build`, `.next`, `out`, `coverage`, framework caches, and non-JS trees such as `.venv`, `vendor` and `target`), so in a workspace it still sees `apps/` and `packages/`. It does not walk up to a workspace root or into workspace packages. In a monorepo, run it from the directory that contains both `.git` and the app's `package.json`. Split layouts (`.git` at the root, the app's `package.json` in `apps/web/`) are unsupported in v1. ## Overwrite warnings diff --git a/src/lib/detect-archetype.ts b/src/lib/detect-archetype.ts index d7bcd5a4..e7ae4913 100644 --- a/src/lib/detect-archetype.ts +++ b/src/lib/detect-archetype.ts @@ -1,4 +1,11 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { + closeSync, + constants, + fstatSync, + openSync, + readSync, + readdirSync, +} from 'node:fs'; import { join, resolve } from 'node:path'; import { archetypesFromSignals, @@ -83,6 +90,16 @@ export const SKIP_DIRS: ReadonlySet = new Set([ '.cache', // generic tool cache (Parcel, Gatsby, …) '.parcel-cache', // Parcel cache 'storybook-static', // Storybook static build + // Non-JS dependency/build trees that share a repo with a JS app. Same LOST- + // signal tradeoff as above: hand-authored source under e.g. `vendor/` goes + // dark (package.json still backstops it); a 50k-file `.venv` no longer + // exhausts MAX_ENTRIES before the walk reaches `src/`. + '.venv', // Python virtualenv + 'venv', // Python virtualenv + '__pycache__', // Python bytecode cache + 'vendor', // Go / PHP (Composer) / Ruby vendored dependencies + 'target', // Rust / Maven build output + '.yarn', // Yarn Berry cache / PnP store ]); // Bounded walk. These caps are a DEFENSIVE bound on a pathological tree, NOT a @@ -155,9 +172,58 @@ export function scanFileTreeSignals(root: string): ArchetypeSignals { return acc; } +// A package.json larger than this is not one pharn will read (real manifests +// are a few KB; the largest public ones are well under 1 MiB). Past it the file +// is "not usable", exactly like a parse error. +export const MAX_PACKAGE_JSON_BYTES = 4 * 1024 * 1024; + +// O_NONBLOCK keeps the open of a FIFO from blocking forever (the descriptor's +// type is then refused below). POSIX-only; absent on win32, where the flag is +// simply not set. A symlinked package.json is deliberately still FOLLOWED — it +// is the user's own project — and bounded by the regular-file check + the cap. +const PKG_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0); + +/** + * Read `/package.json`'s text, or `null` when it is absent or unusable. + * ONE descriptor, opened once: its type is checked with `fstat` and the bytes + * are read from the same descriptor into a fixed buffer, so a FIFO, a device + * (a symlink to `/dev/zero`) or a huge file can neither hang nor exhaust memory. + * Never throws — detection must still proceed on file-tree signals. + */ +function readPackageJsonText(pkgPath: string): string | null { + let fd: number; + try { + fd = openSync(pkgPath, PKG_OPEN_FLAGS); + } catch { + return null; + } + try { + if (!fstatSync(fd).isFile()) return null; + const buf = Buffer.alloc(MAX_PACKAGE_JSON_BYTES + 1); + let total = 0; + while (total < buf.length) { + const n = readSync(fd, buf, total, buf.length - total, null); + if (n === 0) break; + total += n; + } + if (total > MAX_PACKAGE_JSON_BYTES) return null; + // A leading UTF-8 BOM (some Windows editors write one) is not JSON; + // `JSON.parse` would reject the whole manifest and misdetect the project. + return buf + .subarray(0, total) + .toString('utf8') + .replace(/^\uFEFF/, ''); + } catch { + return null; + } finally { + closeSync(fd); + } +} + /** * Read `/package.json` and reduce it to its raw ArchetypeSignals, reporting - * whether a usable manifest was found. Missing file, parse error, or a non-object + * whether a usable manifest was found. Missing file, unusable file (not a + * regular file, over MAX_PACKAGE_JSON_BYTES), parse error, or a non-object * top-level value all yield the empty signal set with `packageJsonFound: false`; * a found, parseable object yields its signals with `true`. */ @@ -166,10 +232,10 @@ function readPackageSignals(cwd: string): { packageJsonFound: boolean; } { const empty = packageSignals({}); - const pkgPath = resolve(cwd, 'package.json'); - if (!existsSync(pkgPath)) return { pkgSig: empty, packageJsonFound: false }; + const text = readPackageJsonText(resolve(cwd, 'package.json')); + if (text === null) return { pkgSig: empty, packageJsonFound: false }; try { - const parsed: unknown = JSON.parse(readFileSync(pkgPath, 'utf8')); + const parsed: unknown = JSON.parse(text); if ( typeof parsed !== 'object' || parsed === null || diff --git a/tests/detect-archetype.test.ts b/tests/detect-archetype.test.ts index 45835480..a2b3fcd4 100644 --- a/tests/detect-archetype.test.ts +++ b/tests/detect-archetype.test.ts @@ -1,7 +1,9 @@ +import { execFileSync } from 'node:child_process'; import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { + MAX_PACKAGE_JSON_BYTES, SKIP_DIRS, detectArchetypesFromProject, scanFileTreeSignals, @@ -119,6 +121,85 @@ describe('detectArchetypesFromProject', () => { }); }); +// PHARN-15: the package.json read is ONE bounded descriptor read. Every +// unusable shape yields `packageJsonFound: false` and never throws or blocks. +describe('detectArchetypesFromProject — package.json read hardening', () => { + const tmp = useTmpDir(); + + it('strips a UTF-8 BOM (a Next.js manifest still detects ssr)', () => { + writeFileSync( + join(tmp.path(), 'package.json'), + '\uFEFF' + JSON.stringify({ dependencies: { next: '15' } }), + ); + const result = detectArchetypesFromProject(tmp.path()); + expect(result.packageJsonFound).toBe(true); + expect(result.archetypes).toContain('ssr'); + }); + + it('treats a manifest over the size cap as not found', () => { + const pad = ' '.repeat(MAX_PACKAGE_JSON_BYTES); + writeFileSync( + join(tmp.path(), 'package.json'), + `{"dependencies":{"next":"15"}}${pad}`, + ); + expect(detectArchetypesFromProject(tmp.path()).packageJsonFound).toBe( + false, + ); + }); + + it('treats a directory at package.json as not found', () => { + mkdirSync(join(tmp.path(), 'package.json')); + expect(detectArchetypesFromProject(tmp.path()).packageJsonFound).toBe( + false, + ); + }); + + it("still follows a symlink to a regular manifest (the user's own project)", () => { + const real = join(tmp.path(), 'real-package.json'); + writeFileSync(real, JSON.stringify({ dependencies: { next: '15' } })); + symlinkSync(real, join(tmp.path(), 'package.json')); + const result = detectArchetypesFromProject(tmp.path()); + expect(result.packageJsonFound).toBe(true); + expect(result.archetypes).toContain('ssr'); + }); + + it.skipIf(process.platform === 'win32')( + 'does not block on a FIFO at package.json', + () => { + execFileSync('mkfifo', [join(tmp.path(), 'package.json')]); + const result = detectArchetypesFromProject(tmp.path()); + expect(result.packageJsonFound).toBe(false); + }, + 5000, + ); + + it.skipIf(process.platform === 'win32')( + 'does not read a device without bound (symlink to /dev/zero)', + () => { + symlinkSync('/dev/zero', join(tmp.path(), 'package.json')); + expect(detectArchetypesFromProject(tmp.path()).packageJsonFound).toBe( + false, + ); + }, + 5000, + ); +}); + +// PHARN-15: the uniform SKIP_DIRS pins below iterate the SHIPPED set, so they +// cannot fail when a member is missing. These names are pinned explicitly: a +// UI signal inside any of them is never seen. +describe('SKIP_DIRS — non-JS dependency/build trees', () => { + const tmp = useTmpDir(); + + it.each(['.venv', 'venv', '__pycache__', 'vendor', 'target', '.yarn'])( + '%s/ is skipped', + (dir) => { + touch(tmp.path(), `${dir}/x/Page.tsx`); + expect(scanFileTreeSignals(tmp.path()).clientUi).toBe(false); + }, + ); +}); + describe('detectArchetypesFromProject — file-tree scanning', () => { const tmp = useTmpDir();