diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 42a7a7289..d59f4d995 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -378,9 +378,19 @@ "graphify.cjs", "gsd2-import.cjs", "handshake-serialized.cjs", + "health-diagnostic-rules/agent-install.cjs", + "health-diagnostic-rules/config-validation.cjs", + "health-diagnostic-rules/milestone-archive-hygiene.cjs", + "health-diagnostic-rules/phase-structure.cjs", + "health-diagnostic-rules/roadmap-disk-consistency.cjs", + "health-diagnostic-rules/root-existence.cjs", + "health-diagnostic-rules/state-consistency.cjs", + "health-diagnostic-rules/worktree-health.cjs", "health-diagnostic-types.cjs", "health-diagnostic.cjs", "hook-bus.cjs", + "host-integration-adapters/cline-sdk-binding.cjs", + "host-integration-adapters/imperative-hook-bus.cjs", "host-integration-sdk.cjs", "host-integration.cjs", "host-runtime-detection.cjs", @@ -394,6 +404,16 @@ "installer-migration-authoring.cjs", "installer-migration-report.cjs", "installer-migrations.cjs", + "installer-migrations/000-first-time-baseline.cjs", + "installer-migrations/001-legacy-orphan-files.cjs", + "installer-migrations/002-codex-legacy-hooks-json.cjs", + "installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs", + "installer-migrations/004-prune-stale-pristine-snapshots.cjs", + "installer-migrations/005-opencode-baseline-commands-dir.cjs", + "installer-migrations/006-pi-extension-cjs-to-js.cjs", + "installer-migrations/007-retire-config-root-commonjs-marker.cjs", + "installer-migrations/008-cursor-retire-commands-surface.cjs", + "installer-migrations/009-pi-retire-reserved-hooks-dir.cjs", "intel-command-router.cjs", "intel.cjs", "io.cjs", @@ -411,6 +431,9 @@ "model-profiles.cjs", "model-resolver.cjs", "normalize-test-command.cjs", + "observability/event.cjs", + "observability/logger.cjs", + "observability/redaction.cjs", "onboard-projection.cjs", "package-identity.cjs", "package-legitimacy.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 6ed348057..b693ac546 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -432,6 +432,16 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | Module | Responsibility | |--------|----------------| +| `installer-migrations/000-first-time-baseline.cjs` | Installer migration: records the first-time installer migration baseline scan — walks per-runtime install surfaces so pre-existing files are classified before any later migration runs | +| `installer-migrations/001-legacy-orphan-files.cjs` | Installer migration: removes manifest-managed legacy orphan hook files (`hooks/gsd-notify.sh`, `hooks/statusline.js`) | +| `installer-migrations/002-codex-legacy-hooks-json.cjs` | Installer migration: removes legacy Codex `hooks.json` GSD hook registrations | +| `installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs` | Installer migration: removes stale legacy `get-shit-done/` runtime directory files after the rename to `gsd-core/` (#604) | +| `installer-migrations/004-prune-stale-pristine-snapshots.cjs` | Installer migration: removes stale `gsd-pristine/get-shit-done/` snapshot files left behind by the get-shit-done → gsd-core rename (#604, #934) | +| `installer-migrations/005-opencode-baseline-commands-dir.cjs` | Installer migration: baselines pre-existing OpenCode `commands/` (plural) files missed by migration 000's RUNTIME_SURFACES list (#2329 follow-up) | +| `installer-migrations/006-pi-extension-cjs-to-js.cjs` | Installer migration: retires pi's stale `extensions/gsd.cjs` after #2470 renamed the installed native extension to `extensions/gsd.js` | +| `installer-migrations/007-retire-config-root-commonjs-marker.cjs` | Installer migration: retires the config-root `{"type":"commonjs"}` marker that pre-#2544 installs wrote over `/package.json` | +| `installer-migrations/008-cursor-retire-commands-surface.cjs` | Installer migration: retires Cursor's duplicate `commands/` surface now that skills are the sole workflow surface (#2644) | +| `installer-migrations/009-pi-retire-reserved-hooks-dir.cjs` | Installer migration: retires pi's legacy `hooks/` directory after GSD's shared hook bundle moved to `gsd-hooks/` (#3023) | | `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation | | `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates | | `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` | @@ -458,6 +468,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `claude-orchestration.cjs` | Claude Orchestration capability (#1143) — Workflow-tool backend detection + emitter; `detectWorkflowBackend` fail-closed gate (`{available, backend: 'workflow'\|'inline', reason}`, degrades to today's inline behavior unless every gate opens) and `emitWorkflowScript` (maps GSD's wave/plan model onto Workflow primitives: wave → sequential `parallel()` barriers, plan → `agent(...)` with per-plan worktree isolation mirroring the inline path). Pure, zero external dependencies, never throws; never invokes the Workflow tool itself | | `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | +| `host-integration-adapters/cline-sdk-binding.cjs` | Cline SDK binding — pure AgentPlugin `beforeTool` planning-artifact guard and `createAgentModel` model-override resolution adapters, no `@cline/sdk` import (ADR-1239 Phase D, #2090) | | `clock.cjs` | Injectable clock seam (now/sleep) for deterministic lock testing | | `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) | | `code-review-flags.cjs` | Typed flag parser for `/gsd-code-review`; exports `parseCodeReviewFlags(argv)` (→ `{ fix, all, auto, depth, files }`) and `resolveCodeReviewWorkflow(flags)` (→ `'code-review.md' \| 'code-review-fix.md'`); canonical dispatch seam for `--fix`/`--all`/`--auto` routing | @@ -487,6 +498,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `eval-command-router.cjs` | Routes the `eval.score` verb (compiled from `src/eval-command-router.cts`, gitignored) — thin dispatcher into the eval scoring module (#1579) | | `eval.cjs` | Deterministic eval scoring (compiled from `src/eval.cts`, gitignored) — `computeEvalScore` (coverage*0.6 + infra*0.4, bands 80/60/40) + `cmdEvalScore` CLI domain guard; moves the gsd-eval-auditor's weighted arithmetic out of the prompt into code (#10 / #1579) | | `estimate-cli.cjs` | I/O seam over `phase-estimation.cjs` — the `estimate-check` and `estimate-calibration` query verbs; reads the `workflow.smart_zone_tokens` budget and `.planning/estimation-calibration.json`, both degrading to defaults rather than failing planning (#2630) | +| `observability/event.cjs` | DispatchEvent shape factory for every Hub dispatch — traceId/parentTraceId/command/result/timestamp record consumed by DispatchLogger (#177, ADR-0174 P1.3/P1.4) | | `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`PATH` then `node_modules/.bin`), actionable missing-binary errors, and structural findings normalization | | `federated-config.cjs` | Defensive merge of capability-declared config slices into the loadConfig return value — ADR-857 phase 3b; exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig })` → `{ values, validKeys, warnings }`; live for migrated Capability keys that are atomically removed from the central config schema | | `frontmatter.cjs` | YAML frontmatter CRUD operations | @@ -496,9 +508,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) | | `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` | | `health-diagnostic-types.cjs` | Shared, dependency-free `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types for `validate health` — split out of `health-diagnostic.cjs` so its rule-group files can depend on the enums/types without a CJS circular require back into the evaluator (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | -| `health-diagnostic.cjs` | Frozen rule-table contract for `validate health` — `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums, `Diagnostic`/`Remedy`/`Rule` shapes, the `RULES` table (empty in this phase; a later migration batch appends the 32 rules extracted from `cmdValidateHealth`), `evaluateRules` (throws on duplicate rule codes), and `applyRepairs` (the `--repair`/`--backfill` dispatcher — refuses `DESTRUCTIVE`-risk remedies) (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | +| `health-diagnostic.cjs` | Frozen rule-table contract for `validate health` — `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums, `Diagnostic`/`Remedy`/`Rule` shapes, the fully-wired `RULES` table (the static concatenation of the 31 rules exported by the eight `health-diagnostic-rules/*.cjs` group files), `evaluateRules` (throws on duplicate rule codes), and `applyRepairs` (the real `--repair`/`--backfill` dispatcher with real per-action handlers — refuses `DESTRUCTIVE`-risk remedies) (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` | | `host-runtime-detection.cjs` | Host Runtime Detection Module (ADR-2313 Phase 5, #3245) — the detection rung beneath `GSD_RUNTIME` and `.planning/config.json` `runtime` that lets `init` report `agent_runtime: codex` inside a Codex session instead of the hardcoded `claude` default; `detectHostRuntime` returns the typed `{runtime, source, signal}` from citation-backed Codex signals (`CODEX_SANDBOX`/`CODEX_SANDBOX_NETWORK_DISABLED`, else `CODEX_HOME` + `config.toml`), `resolveReportedRuntime` composes the full ladder. Pure, injectable, never writes, never shells out | +| `host-integration-adapters/imperative-hook-bus.cjs` | Imperative hook-bus adapter — descriptor-driven `hooks.json` binding generalized from the Cursor-specific writer, resolving the negotiated `hookBus` axis against a host's documented `hostBehaviors.managedHookEvents` list; pure, no I/O (ADR-1239 Phase D, #2089) | | `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` | | `init.cjs` | Compound context loading for each workflow type | | `install-effort-resolver.cjs` | Install-time effort resolution — `readGsdEffectiveEffortConfig` (merges `~/.gsd/defaults.json` + project `.planning/config.json`) + `resolveInstallTimeEffort`, extracted from `bin/install.js` (#2071) so `gsd-tools effort sync` can require it from the shipped runtime instead of the never-copied package-root installer; install.js imports them back (single source) | @@ -514,6 +527,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `io.cjs` | CLI I/O primitives — `output`/`error` emission, JSON-error mode, and large-payload temp-file spillover (extracted from `core.cjs`, ADR-857) | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) | +| `observability/logger.cjs` | DispatchLogger interface + default implementation — silent on success, structured stderr JSON on error, opt-in `.gsd-trace.jsonl` audit file (`GSD_AUDIT=1`), args omitted unless `GSD_AUDIT_ARGS=1` (#177, ADR-0174 P1.3) | | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts for the five-step pipeline (discuss/plan/execute/verify/ship); emitted by `scripts/gen-loop-host-contract.cjs --write` (ADR-894 §3); consumed by `gen-capability-registry.cjs` | | `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c/6 registry-consuming query; given a canonical loop point, filters `byLoopPoint` by resolved Capability State plus config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks [--config-dir ]` | | `markdown-sectionizer.cjs` | Canonical markdown-structure parsing seam (ADR-1372, epic #1372) — pure, Node built-ins only; exports `stripFencedCode` (CommonMark-correct fence stripper, CRLF-safe), `stripInlineCode` (per-line CommonMark inline-code-span stripper, #2365), `tokenizeHeadings` (ATX headings outside fenced blocks), `collectSections`/`collectSection` (line-by-line section collection with `bodyStart`/`bodyEnd` offsets), `iterateBullets` (dash/checkbox/numbered markers), `extractTaggedBlocks` (inner text of `…` blocks, caller decides fence-stripping), `replaceSection` (pure character-offset body splice for read-modify-write callers), and `withSection` (resolve a section by heading/predicate and run an edit callback against ONLY its body, splicing the result back — ADR-2143 §4 bounded mutation); foundation for T0–T7 migration tiers retiring 8+ ad-hoc parsers | @@ -543,6 +557,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `profile-pipeline-command-router.cjs` | ADR-959 capability command router for the profile-pipeline command family — dispatches scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase); phase 6 cutover | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) | +| `observability/redaction.cjs` | Arg redaction policy for dispatch events — args omitted from every emitted event by default, opt-in verbatim inclusion via `GSD_AUDIT_ARGS=1`; stateless env read, no module-level caching (#177) | | `refactor-trigger-command-router.cjs` | ADR-959 capability command router for `gsd-tools refactor` (issue #1953) — dispatches evaluate/status/accept/decline subcommands for the complexity-triggered refactor capability; owns capability-activation gating, git invocation (via the `git-base-branch.cjs` `phaseStartCommit`/`changedFilesSince` adapters), config reads, phase-directory resolution, and the optional broken-windows ledger integration around the pure `complexity-trigger.cjs` leaf | | `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) | | `research-store.cjs` | Content-addressed research cache: sha256 keys, per-source TTL staleness, two-tier (user ~/.gsd / project .planning) store | diff --git a/scripts/gen-inventory-manifest.cjs b/scripts/gen-inventory-manifest.cjs index e65c7c580..094e97023 100644 --- a/scripts/gen-inventory-manifest.cjs +++ b/scripts/gen-inventory-manifest.cjs @@ -117,6 +117,47 @@ function statOrNull(p) { } } +/** + * Collect `//` entries as `/` keys — ONE level of + * subdirectory beneath `dir` itself, where the subdirectory's NAME is the thing being + * collected (unlike `collectNested`, there is no fixed subdir name to look for; every + * child directory of `dir` is scanned). This is what makes `gsd-core/bin/lib//*.cjs` + * (e.g. `health-diagnostic-rules/`, `installer-migrations/`, `host-integration-adapters/`, + * `observability/`) visible to the `cli_modules` family, mirroring the shape + * `docs/INVENTORY.md`'s CLI Modules table already uses for these files. + * + * Same defensive `statOrNull`-based style as `collectNested`: a stat/readdir failure on + * one entry is swallowed rather than thrown, so one unreadable subdirectory cannot take + * down `--check` for the whole repo. + */ +function collectOneLevelSubdirs({ dir, filter }) { + if (!fs.existsSync(dir)) return []; + const out = []; + let children; + try { + children = fs.readdirSync(dir); + } catch { + return []; + } + for (const child of children) { + const childStat = statOrNull(path.join(dir, child)); + if (!childStat || !childStat.isDirectory()) continue; + const subdirPath = path.join(dir, child); + let files; + try { + files = fs.readdirSync(subdirPath); + } catch { + continue; + } + for (const file of files) { + const fileStat = statOrNull(path.join(subdirPath, file)); + if (!fileStat || !fileStat.isFile() || !filter(file)) continue; + out.push([child, file].join('/')); + } + } + return out.sort(); +} + function collectNested({ root, subdir, filter }) { if (!fs.existsSync(root)) return []; const out = []; @@ -150,11 +191,16 @@ function collectNested({ root, subdir, filter }) { function buildManifest() { const manifest = { families: {} }; for (const { name, dir, filter, toName } of FAMILIES) { - manifest.families[name] = fs + const flat = fs .readdirSync(dir) .filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f)) - .map(toName) - .sort(); + .map(toName); + // `cli_modules` also ships subdirectory modules (`health-diagnostic-rules/`, + // `installer-migrations/`, `host-integration-adapters/`, `observability/`) invisible to + // the flat readdirSync above; merge them into the SAME sorted array, matching the single + // "CLI Modules" table shape docs/INVENTORY.md already uses (#3309). + const nested = name === 'cli_modules' ? collectOneLevelSubdirs({ dir, filter }) : []; + manifest.families[name] = [...flat, ...nested].sort(); } for (const family of NESTED_FAMILIES) { manifest.families[family.name] = collectNested(family); @@ -209,4 +255,4 @@ if (require.main === module) { // `DEFECT.GENERATIVE-FIX` divergence class: adding a family here while the test kept // its own list meant the test silently verified fewer families than shipped, and still // passed. The test now imports these, so the two surfaces cannot drift. -module.exports = { FAMILIES, NESTED_FAMILIES, collectNested, buildManifest }; +module.exports = { FAMILIES, NESTED_FAMILIES, collectNested, collectOneLevelSubdirs, buildManifest }; diff --git a/tests/inventory-manifest-sync.test.cjs b/tests/inventory-manifest-sync.test.cjs index 5108b1f97..9c1a94b75 100644 --- a/tests/inventory-manifest-sync.test.cjs +++ b/tests/inventory-manifest-sync.test.cjs @@ -20,7 +20,7 @@ const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json'); // a family added to the generator but not here left this test silently verifying a // subset while still reporting green. Importing makes divergence impossible rather than // merely detectable. -const { FAMILIES, NESTED_FAMILIES, collectNested } = require('../scripts/gen-inventory-manifest.cjs'); +const { FAMILIES, NESTED_FAMILIES, collectNested, collectOneLevelSubdirs } = require('../scripts/gen-inventory-manifest.cjs'); test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => { const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); @@ -28,11 +28,14 @@ test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => { const removals = []; for (const { name, dir, filter, toName } of FAMILIES) { - const live = new Set( - fs.readdirSync(dir) - .filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f)) - .map(toName), - ); + const flat = fs.readdirSync(dir) + .filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f)) + .map(toName); + // `cli_modules` also ships one level of subdirectory modules (#3309); mirror + // buildManifest's special-case merge exactly, or this test would report every + // subdirectory file as a phantom removal. + const nested = name === 'cli_modules' ? collectOneLevelSubdirs({ dir, filter }) : []; + const live = new Set([...flat, ...nested]); const recorded = new Set((committed.families || {})[name] || []); for (const entry of live) { diff --git a/tests/inventory-nested-families.test.cjs b/tests/inventory-nested-families.test.cjs index bacb06d57..d370eded7 100644 --- a/tests/inventory-nested-families.test.cjs +++ b/tests/inventory-nested-families.test.cjs @@ -18,10 +18,11 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { collectNested } = require('../scripts/gen-inventory-manifest.cjs'); +const { collectNested, collectOneLevelSubdirs } = require('../scripts/gen-inventory-manifest.cjs'); const { cleanup } = require('./helpers.cjs'); const MD_ONLY = (f) => f.endsWith('.md'); +const CJS_ONLY = (f) => f.endsWith('.cjs'); /** Build a fixture tree: {parentName: {subdirName: [fileNames]}}. */ function buildTree(spec) { @@ -174,3 +175,104 @@ test('collectNested is deterministic and sorted', (t) => { assert.deepStrictEqual(first, second); assert.deepStrictEqual(first, ['alpha/steps/c.md', 'zeta/steps/a.md', 'zeta/steps/b.md']); }); + +// ─── collectOneLevelSubdirs — cli_modules one-level subdir scan (#3309) ─────── +// +// Unlike collectNested's FIXED subdir name (many parents, one shared child-dir +// name like `steps`), this helper's `dir` IS the parent, and every one of ITS +// child directories is the thing being collected — e.g. `bin/lib/health-diagnostic-rules/`. + +/** Build a fixture tree directly under `dir`: {subdirName: [fileNames]} \| flat file list. */ +function buildSubdirTree(dir, spec) { + for (const [subdir, files] of Object.entries(spec)) { + const subdirPath = path.join(dir, subdir); + fs.mkdirSync(subdirPath, { recursive: true }); + for (const f of files) fs.writeFileSync(path.join(subdirPath, f), '// fixture\n'); + } +} + +test('collectOneLevelSubdirs picks up a file inside a subdirectory', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { 'health-diagnostic-rules': ['root-existence.cjs'] }); + + assert.deepStrictEqual( + collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), + ['health-diagnostic-rules/root-existence.cjs'], + ); +}); + +test('collectOneLevelSubdirs merges multiple subdirectories, sorted', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { + observability: ['redaction.cjs', 'event.cjs'], + 'installer-migrations': ['001-x.cjs'], + }); + + assert.deepStrictEqual( + collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), + ['installer-migrations/001-x.cjs', 'observability/event.cjs', 'observability/redaction.cjs'], + ); +}); + +test('collectOneLevelSubdirs contributes nothing for an empty subdirectory', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + fs.mkdirSync(path.join(dir, 'empty-subdir'), { recursive: true }); + + assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), []); +}); + +test('collectOneLevelSubdirs ignores files directly in dir (flat scan owns those)', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + fs.writeFileSync(path.join(dir, 'top-level.cjs'), '// fixture\n'); + buildSubdirTree(dir, { subdir: ['nested.cjs'] }); + + assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/nested.cjs']); +}); + +test('collectOneLevelSubdirs applies the filter and ignores non-matching files', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { subdir: ['keep.cjs', 'skip.md', 'skip.json'] }); + + assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/keep.cjs']); +}); + +test('collectOneLevelSubdirs does not recurse past one level', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { subdir: ['top.cjs'] }); + const deep = path.join(dir, 'subdir', 'deeper'); + fs.mkdirSync(deep, { recursive: true }); + fs.writeFileSync(path.join(deep, 'too-deep.cjs'), '// fixture\n'); + + assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/top.cjs']); +}); + +test('collectOneLevelSubdirs on a missing dir contributes nothing rather than throwing', () => { + assert.deepStrictEqual( + collectOneLevelSubdirs({ dir: path.join(os.tmpdir(), 'gsd-3309-does-not-exist'), filter: CJS_ONLY }), + [], + ); +}); + +test('collectOneLevelSubdirs skips a dangling symlink without crashing the walk', (t) => { + if (process.platform === 'win32') { + t.skip('symlink creation requires elevation on Windows; the unstattable-entry path is asserted on macOS + Linux'); + return; + } + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { subdir: ['real.cjs'] }); + fs.symlinkSync(path.join(dir, 'subdir', 'nope.cjs'), path.join(dir, 'subdir', 'dangling.cjs')); + + assert.deepStrictEqual( + collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), + ['subdir/real.cjs'], + 'one unreadable entry must not take down the whole scan', + ); +});