Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .dev/features/bounded-package-json/GRILL.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions .dev/features/bounded-package-json/PLAN.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions .dev/features/bounded-package-json/REGRESSION.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions .dev/features/bounded-package-json/REVIEW.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions .dev/features/bounded-package-json/SHIP.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions .dev/features/bounded-package-json/VERIFY.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .dev/features/bounded-package-json/regression-report.json
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 17 additions & 0 deletions .dev/features/bounded-package-json/verify-report.json
Original file line number Diff line number Diff line change
@@ -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": []
}
}
4 changes: 2 additions & 2 deletions .pharn/writes-scope.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 1 addition & 1 deletion docs/commands/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 71 additions & 5 deletions src/lib/detect-archetype.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -83,6 +90,16 @@ export const SKIP_DIRS: ReadonlySet<string> = 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
Expand Down Expand Up @@ -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 `<cwd>/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 `<cwd>/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`.
*/
Expand All @@ -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 ||
Expand Down
Loading
Loading