diff --git a/.gitignore b/.gitignore index 6819519db..6308fdcf7 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ build/ /gsd-core/bin/lib/runtime-config-adapter-registry.cjs /gsd-core/bin/lib/command-routing-hub.cjs /gsd-core/bin/lib/core.cjs +/gsd-core/bin/lib/core-utils.cjs /gsd-core/bin/lib/io.cjs /gsd-core/bin/lib/phase-id.cjs /gsd-core/bin/lib/roadmap-parser.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 92a884183..4c619e31c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -115,6 +115,9 @@ Module owning the tool's CLI I/O primitives: `output()` result emission (with la ### Roadmap Parser Module Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; `core.cjs` re-exports the helpers for back-compat. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`). +### Core Utilities Module +Module owning the shared low-level utility primitives extracted from Core: POSIX path normalization (`toPosixPath`), filesystem scanning (`detectSubRepos`, `readSubdirectories`, `getPhaseFileStats`, `pathExistsInternal`), and small pure helpers (`generateSlugInternal`, `extractOneLinerFromBody`, `filterPlanFiles`, `filterSummaryFiles`, `extractCanonicalPlanId`, `timeAgo`). Depends only on Node built-ins and already-leafed modules (`phase-id` for `comparePhaseNum`, `planning-workspace` for `findContextMdIn`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2c (#877) as the shared leaf that unblocks the phase-locator fs-search extraction (2d); `core.cjs` re-exports the public helpers for back-compat. Source of truth: `gsd-core/bin/lib/core-utils.cjs` (generated from `src/core-utils.cts`). + ### Package Identity Module [Planned] Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea83a45eb..d0a93506c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -342,6 +342,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | Module | Responsibility | | ---------------------- | --------------------------------------------------------------------------------------------------- | +| `core-utils.cjs` | Shared low-level utility primitives — POSIX path normalization, sub-repo/subdirectory scanning, phase file stats, slug/one-liner/plan-id helpers, time-ago (extracted from `core.cjs`, ADR-857) | | `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers | | `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover | | `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, regex builders (extracted from `core.cjs`, ADR-857) | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 31d1c607d..baa67ffd0 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -285,6 +285,7 @@ "config.cjs", "configuration.cjs", "context-utilization.cjs", + "core-utils.cjs", "core.cjs", "decisions.cjs", "docs.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 21458ddac..fe77ef96f 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (93 shipped) +## CLI Modules (94 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -396,6 +396,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `config-types.cjs` | TypeScript type definitions for the `model_policy` config block — `ModelPolicyConfig`, `TierEntry`, `RuntimeTiers`; compiled from `src/config-types.cts` at publish time (ADR-457) | | `configuration.cjs` | Configuration Module — canonical config loading, legacy-key normalization, defaults merge, and explicit on-disk migration; source of truth for both SDK and CJS consumers | | `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) | +| `core-utils.cjs` | Shared low-level utilities — POSIX path normalization, sub-repo/subdirectory scanning, phase file stats, slug/one-liner/plan-id helpers, time-ago (extracted from `core.cjs`, ADR-857) | | `core.cjs` | Shared utilities and runtime fallbacks; compatibility re-exports for planning-workspace and I/O (`io.cjs`) helpers | | `decisions.cjs` | Parses CONTEXT.md `` blocks; accepts numeric (D-42) and alphanumeric (D-INFRA-01) IDs; returns `{id, text, category, tags, trackable}` | | `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection | diff --git a/eslint.config.mjs b/eslint.config.mjs index a50b32cd5..5df0b730a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -89,6 +89,7 @@ export default tseslint.config( 'gsd-core/bin/lib/runtime-config-adapter-registry.cjs', 'gsd-core/bin/lib/command-routing-hub.cjs', 'gsd-core/bin/lib/core.cjs', + 'gsd-core/bin/lib/core-utils.cjs', 'gsd-core/bin/lib/io.cjs', 'gsd-core/bin/lib/phase-id.cjs', 'gsd-core/bin/lib/roadmap-parser.cjs', diff --git a/src/core-utils.cts b/src/core-utils.cts new file mode 100644 index 000000000..ec0d6a60a --- /dev/null +++ b/src/core-utils.cts @@ -0,0 +1,201 @@ +/** + * Core Utilities — Shared low-level utility primitives + * + * ADR-857 rollout phase 2c: extracted from core.cts (issue #877). + * Owns POSIX path normalization, sub-repo/subdirectory scanning, + * phase file stats, slug/one-liner/plan-id helpers, and time-ago. + * Behaviour is preserved byte-for-behaviour from the prior location; + * only the module boundary moved. core.cjs re-exports every public symbol + * here under its own `export =` object so existing consumers are unaffected. + * + * New imports should pull core-utils helpers from core-utils.cjs directly. + * + * Dependencies (leaf modules only — no core.cjs, no loadConfig): + * - node:fs / node:path (stdlib) + * - ./phase-id.cjs (comparePhaseNum, used by readSubdirectories) + * - ./planning-workspace.cjs (findContextMdIn, used by getPhaseFileStats) + */ + +import fs from 'node:fs'; +import path from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdModule = require('./phase-id.cjs'); +const { comparePhaseNum } = phaseIdModule; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningWorkspace = require('./planning-workspace.cjs'); +const { findContextMdIn } = planningWorkspace; + +// ─── Path helpers ──────────────────────────────────────────────────────────── + +/** Normalize a relative path to always use forward slashes (cross-platform). */ +function toPosixPath(p: string): string { + return p.split(path.sep).join('/'); +} + +/** + * Scan immediate child directories for separate git repos. + * Returns a sorted array of directory names that have their own `.git`. + * Excludes hidden directories and node_modules. + */ +function detectSubRepos(cwd: string): string[] { + const results: string[] = []; + try { + const entries = fs.readdirSync(cwd, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const gitPath = path.join(cwd, entry.name, '.git'); + try { + if (fs.existsSync(gitPath)) { + results.push(entry.name); + } + } catch { /* ignore */ } + } + } catch { /* ignore */ } + return results.sort(); +} + +// ─── Summary body helpers ───────────────────────────────────────────────── + +/** + * Extract a one-liner from the summary body when it's not in frontmatter. + */ +function extractOneLinerFromBody(content: string | null | undefined): string | null { + if (!content) return null; + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, ''); + const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m); + if (!match) return null; + const boldInner = match[1].trim(); + const afterBold = match[2]; + if (/:\s*$/.test(boldInner)) { + const prose = afterBold.trim(); + return prose.length > 0 ? prose : null; + } + return boldInner.length > 0 ? boldInner : null; +} + +// ─── Misc utilities ─────────────────────────────────────────────────────────── + +function pathExistsInternal(cwd: string, targetPath: string): boolean { + const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); + try { + fs.statSync(fullPath); + return true; + } catch { + return false; + } +} + +function generateSlugInternal(text: string | null | undefined): string | null { + if (!text) return null; + return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60); +} + +// ─── Phase file helpers ────────────────────────────────────────────────────── + +/** Filter a file list to just PLAN.md / *-PLAN.md entries. */ +function filterPlanFiles(files: string[]): string[] { + return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); +} + +/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */ +function filterSummaryFiles(files: string[]): string[] { + return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); +} + +interface PhaseFileStats { + plans: string[]; + summaries: string[]; + hasResearch: boolean; + hasContext: boolean; + hasVerification: boolean; + hasReviews: boolean; +} + +/** + * Read a phase directory and return counts/flags for common file types. + */ +function getPhaseFileStats(phaseDir: string): PhaseFileStats { + const files = fs.readdirSync(phaseDir); + return { + plans: filterPlanFiles(files), + summaries: filterSummaryFiles(files), + hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), + hasContext: findContextMdIn(files) !== null, + hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'), + hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'), + }; +} + +/** + * Read immediate child directories from a path. + * Returns [] if the path doesn't exist or can't be read. + * Pass sort=true to apply comparePhaseNum ordering. + */ +function readSubdirectories(dirPath: string, sort = false): string[] { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs; + } catch { + return []; + } +} + +/** + * Format a Date as a fuzzy relative time string (e.g. "5 minutes ago"). + */ +function timeAgo(date: Date): string { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds} seconds ago`; + const minutes = Math.floor(seconds / 60); + if (minutes === 1) return '1 minute ago'; + if (minutes < 60) return `${minutes} minutes ago`; + const hours = Math.floor(minutes / 60); + if (hours === 1) return '1 hour ago'; + if (hours < 24) return `${hours} hours ago`; + const days = Math.floor(hours / 24); + if (days === 1) return '1 day ago'; + if (days < 30) return `${days} days ago`; + const months = Math.floor(days / 30); + if (months === 1) return '1 month ago'; + if (months < 12) return `${months} months ago`; + const years = Math.floor(days / 365); + if (years === 1) return '1 year ago'; + return `${years} years ago`; +} + +// ─── Plan ID helpers ───────────────────────────────────────────────────────── + +/** + * Extract the canonical plan ID from a filename. + * Private to the core cluster — exported so core.cjs:searchPhaseInDir can + * import it from this leaf without circular dependency, but NOT re-exported + * from core.cjs's public `export =` block. + */ +function extractCanonicalPlanId(filename: string): string { + const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, ''); + const parts = base.split('-').filter(Boolean); + const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i; + const phaseIdx = parts.findIndex(p => tokenRe.test(p)); + if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) { + return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`; + } + return base; +} + +export = { + toPosixPath, + detectSubRepos, + extractOneLinerFromBody, + pathExistsInternal, + generateSlugInternal, + filterPlanFiles, + filterSummaryFiles, + getPhaseFileStats, + readSubdirectories, + timeAgo, + extractCanonicalPlanId, +}; diff --git a/src/core.cts b/src/core.cts index d608163c4..f5cef5faa 100644 --- a/src/core.cts +++ b/src/core.cts @@ -42,8 +42,22 @@ const { withPlanningLock, getActiveWorkstream, setActiveWorkstream, - findContextMdIn, } = planningWorkspace; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import coreUtilsModule = require('./core-utils.cjs'); +const { + toPosixPath, + detectSubRepos, + extractOneLinerFromBody, + pathExistsInternal, + generateSlugInternal, + filterPlanFiles, + filterSummaryFiles, + getPhaseFileStats, + readSubdirectories, + timeAgo, + extractCanonicalPlanId, +} = coreUtilsModule; import { findProjectRoot } from './project-root.cjs'; import { getGlobalConfigDir } from './runtime-homes.cjs'; @@ -54,34 +68,9 @@ import configSchema = require('./config-schema.cjs'); const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema; // ─── Path helpers ──────────────────────────────────────────────────────────── - -/** Normalize a relative path to always use forward slashes (cross-platform). */ -function toPosixPath(p: string): string { - return p.split(path.sep).join('/'); -} - -/** - * Scan immediate child directories for separate git repos. - * Returns a sorted array of directory names that have their own `.git`. - * Excludes hidden directories and node_modules. - */ -function detectSubRepos(cwd: string): string[] { - const results: string[] = []; - try { - const entries = fs.readdirSync(cwd, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; - const gitPath = path.join(cwd, entry.name, '.git'); - try { - if (fs.existsSync(gitPath)) { - results.push(entry.name); - } - } catch { /* ignore */ } - } - } catch { /* ignore */ } - return results.sort(); -} +// toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877). +// The destructured bindings above (from coreUtilsModule) make them available to +// core-internal callers; core.cjs re-exports toPosixPath and detectSubRepos for back-compat. // findProjectRoot is now re-exported from the generated CJS module above. @@ -530,16 +519,10 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] { // extractPhaseToken, phaseTokenMatches // — all imported via `phaseIdModule` above; internal callers use the destructured bindings. -function extractCanonicalPlanId(filename: string): string { - const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, ''); - const parts = base.split('-').filter(Boolean); - const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i; - const phaseIdx = parts.findIndex(p => tokenRe.test(p)); - if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) { - return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`; - } - return base; -} +// extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877). +// The destructured binding above (from coreUtilsModule) makes it available to +// core-internal callers (searchPhaseInDir). It is NOT in core.cjs's public export = +// block (it was never public). interface PhaseSearchResult { found: boolean; @@ -1307,37 +1290,14 @@ function resolveEffortForTier(cwd: string, agentType: string, attempt?: number): return current; } -// ─── Summary body helpers ───────────────────────────────────────────────── +// ─── Summary body helpers / Misc utilities / Phase file helpers ─────────────── +// extractOneLinerFromBody, pathExistsInternal, generateSlugInternal, +// filterPlanFiles, filterSummaryFiles, getPhaseFileStats, readSubdirectories, +// timeAgo — all moved to core-utils.cjs (ADR-857 phase 2c / #877). +// The destructured bindings above (from coreUtilsModule) make them available +// to core-internal callers; core.cjs re-exports the public ones for back-compat. -/** - * Extract a one-liner from the summary body when it's not in frontmatter. - */ -function extractOneLinerFromBody(content: string | null | undefined): string | null { - if (!content) return null; - const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, ''); - const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m); - if (!match) return null; - const boldInner = match[1].trim(); - const afterBold = match[2]; - if (/:\s*$/.test(boldInner)) { - const prose = afterBold.trim(); - return prose.length > 0 ? prose : null; - } - return boldInner.length > 0 ? boldInner : null; -} - -// ─── Misc utilities ─────────────────────────────────────────────────────────── - -function pathExistsInternal(cwd: string, targetPath: string): boolean { - const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); - try { - fs.statSync(fullPath); - return true; - } catch { - return false; - } -} +// ─── Misc utilities (remaining in core) ────────────────────────────────────── interface GitWorktreeInfo { inside: boolean; @@ -1369,89 +1329,9 @@ function gitWorktreeInfoInternal(cwd: string): GitWorktreeInfo { } } -function generateSlugInternal(text: string | null | undefined): string | null { - if (!text) return null; - return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60); -} - // MilestoneInfo, MilestonePhaseFilter, getMilestoneInfo, getMilestonePhaseFilter // — all re-exported from roadmap-parser.cjs via roadmapParserModule above. -// ─── Phase file helpers ────────────────────────────────────────────────────── - -/** Filter a file list to just PLAN.md / *-PLAN.md entries. */ -function filterPlanFiles(files: string[]): string[] { - return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); -} - -/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */ -function filterSummaryFiles(files: string[]): string[] { - return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); -} - -interface PhaseFileStats { - plans: string[]; - summaries: string[]; - hasResearch: boolean; - hasContext: boolean; - hasVerification: boolean; - hasReviews: boolean; -} - -/** - * Read a phase directory and return counts/flags for common file types. - */ -function getPhaseFileStats(phaseDir: string): PhaseFileStats { - const files = fs.readdirSync(phaseDir); - return { - plans: filterPlanFiles(files), - summaries: filterSummaryFiles(files), - hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), - hasContext: findContextMdIn(files) !== null, - hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'), - hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'), - }; -} - -/** - * Read immediate child directories from a path. - * Returns [] if the path doesn't exist or can't be read. - * Pass sort=true to apply comparePhaseNum ordering. - */ -function readSubdirectories(dirPath: string, sort = false): string[] { - try { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs; - } catch { - return []; - } -} - -/** - * Format a Date as a fuzzy relative time string (e.g. "5 minutes ago"). - */ -function timeAgo(date: Date): string { - const seconds = Math.floor((Date.now() - date.getTime()) / 1000); - if (seconds < 5) return 'just now'; - if (seconds < 60) return `${seconds} seconds ago`; - const minutes = Math.floor(seconds / 60); - if (minutes === 1) return '1 minute ago'; - if (minutes < 60) return `${minutes} minutes ago`; - const hours = Math.floor(minutes / 60); - if (hours === 1) return '1 hour ago'; - if (hours < 24) return `${hours} hours ago`; - const days = Math.floor(hours / 24); - if (days === 1) return '1 day ago'; - if (days < 30) return `${days} days ago`; - const months = Math.floor(days / 30); - if (months === 1) return '1 month ago'; - if (months < 12) return `${months} months ago`; - const years = Math.floor(days / 365); - if (years === 1) return '1 year ago'; - return `${years} years ago`; -} - export = { output, error, diff --git a/tests/core-utils.test.cjs b/tests/core-utils.test.cjs new file mode 100644 index 000000000..22ac6aba5 --- /dev/null +++ b/tests/core-utils.test.cjs @@ -0,0 +1,578 @@ +/** + * Tests for src/core-utils.cts (compiled to gsd-core/bin/lib/core-utils.cjs). + * + * Verifies behavioural contracts of the utilities extracted from core.cjs + * per ADR-857 rollout phase 2c (#877): + * - toPosixPath + * - detectSubRepos + * - extractOneLinerFromBody + * - pathExistsInternal + * - generateSlugInternal + * - filterPlanFiles + * - filterSummaryFiles + * - getPhaseFileStats + * - readSubdirectories + * - timeAgo + * - extractCanonicalPlanId (private — only via coreUtils, NOT via core) + * - core.cjs re-export shims resolve to the exact same functions (shim-identity) + * + * Adversarial inputs per QA matrix: path-traversal-like names, unicode, + * decimal phase ids, missing/empty dirs, fs edge cases. + * Uses helpers.cjs createTempProject/cleanup for filesystem tests. + */ + +'use strict'; + +const { test, describe, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const coreUtils = require('../gsd-core/bin/lib/core-utils.cjs'); +const core = require('../gsd-core/bin/lib/core.cjs'); +const { cleanup } = require('./helpers.cjs'); + +// ─── Shim-identity assertions ───────────────────────────────────────────────── + +describe('core-utils: shim-identity — core.cjs re-exports same function objects', () => { + test('core.toPosixPath === coreUtils.toPosixPath', () => { + assert.strictEqual(core.toPosixPath, coreUtils.toPosixPath); + }); + test('core.detectSubRepos === coreUtils.detectSubRepos', () => { + assert.strictEqual(core.detectSubRepos, coreUtils.detectSubRepos); + }); + test('core.extractOneLinerFromBody === coreUtils.extractOneLinerFromBody', () => { + assert.strictEqual(core.extractOneLinerFromBody, coreUtils.extractOneLinerFromBody); + }); + test('core.pathExistsInternal === coreUtils.pathExistsInternal', () => { + assert.strictEqual(core.pathExistsInternal, coreUtils.pathExistsInternal); + }); + test('core.generateSlugInternal === coreUtils.generateSlugInternal', () => { + assert.strictEqual(core.generateSlugInternal, coreUtils.generateSlugInternal); + }); + test('core.filterPlanFiles === coreUtils.filterPlanFiles', () => { + assert.strictEqual(core.filterPlanFiles, coreUtils.filterPlanFiles); + }); + test('core.filterSummaryFiles === coreUtils.filterSummaryFiles', () => { + assert.strictEqual(core.filterSummaryFiles, coreUtils.filterSummaryFiles); + }); + test('core.getPhaseFileStats === coreUtils.getPhaseFileStats', () => { + assert.strictEqual(core.getPhaseFileStats, coreUtils.getPhaseFileStats); + }); + test('core.readSubdirectories === coreUtils.readSubdirectories', () => { + assert.strictEqual(core.readSubdirectories, coreUtils.readSubdirectories); + }); + test('core.timeAgo === coreUtils.timeAgo', () => { + assert.strictEqual(core.timeAgo, coreUtils.timeAgo); + }); + test('extractCanonicalPlanId is NOT re-exported from core', () => { + assert.strictEqual(typeof core.extractCanonicalPlanId, 'undefined'); + }); + test('extractCanonicalPlanId IS exported from coreUtils', () => { + assert.strictEqual(typeof coreUtils.extractCanonicalPlanId, 'function'); + }); +}); + +// ─── toPosixPath ───────────────────────────────────────────────────────────── + +describe('toPosixPath', () => { + test('forward-slash paths are unchanged', () => { + assert.strictEqual(coreUtils.toPosixPath('foo/bar/baz'), 'foo/bar/baz'); + }); + + test('empty string returns empty string', () => { + assert.strictEqual(coreUtils.toPosixPath(''), ''); + }); + + test('single segment (no separators) is unchanged', () => { + assert.strictEqual(coreUtils.toPosixPath('file.txt'), 'file.txt'); + }); + + test('platform path.sep is normalized to /', () => { + // On POSIX this is a no-op; on Windows it converts backslashes. + const sep = path.sep; + const p = ['a', 'b', 'c'].join(sep); + assert.strictEqual(coreUtils.toPosixPath(p), 'a/b/c'); + }); + + test('adversarial: path-traversal-like string with backslash separators', () => { + // On POSIX, path.sep === '/' so backslashes are treated as literal characters + // and toPosixPath leaves them as-is (split on '/' only finds one token). + // On Windows (where path.sep === '\\'), backslashes would be normalized to '/'. + // Either way, the result is a string and does not throw. + const result = coreUtils.toPosixPath('..\\..\\etc\\passwd'); + assert.strictEqual(typeof result, 'string'); + if (path.sep === '\\') { + // Windows: separators normalized + assert.ok(result.includes('/')); + assert.ok(!result.includes('\\')); + } else { + // POSIX: backslash is a literal char, not a separator + assert.ok(result.includes('\\')); + } + }); + + test('unicode in path segments passes through', () => { + const result = coreUtils.toPosixPath('中文/path/to/file'); + assert.strictEqual(result, '中文/path/to/file'); + }); +}); + +// ─── detectSubRepos ─────────────────────────────────────────────────────────── + +describe('detectSubRepos', () => { + let tmpDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + test('returns empty array for directory with no sub-repos', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + assert.deepEqual(coreUtils.detectSubRepos(tmpDir), []); + }); + + test('returns empty array for non-existent directory', () => { + assert.deepEqual(coreUtils.detectSubRepos('/nonexistent-path-xyz-' + Date.now()), []); + }); + + test('detects directory with .git as sub-repo', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + const subDir = path.join(tmpDir, 'myrepo'); + fs.mkdirSync(subDir); + fs.mkdirSync(path.join(subDir, '.git')); + assert.deepEqual(coreUtils.detectSubRepos(tmpDir), ['myrepo']); + }); + + test('excludes hidden directories', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + const hiddenDir = path.join(tmpDir, '.hidden'); + fs.mkdirSync(hiddenDir); + fs.mkdirSync(path.join(hiddenDir, '.git')); + assert.deepEqual(coreUtils.detectSubRepos(tmpDir), []); + }); + + test('excludes node_modules', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + const nmDir = path.join(tmpDir, 'node_modules'); + fs.mkdirSync(nmDir); + fs.mkdirSync(path.join(nmDir, '.git')); + assert.deepEqual(coreUtils.detectSubRepos(tmpDir), []); + }); + + test('returns sorted results for multiple sub-repos', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + for (const name of ['z-repo', 'a-repo', 'm-repo']) { + const subDir = path.join(tmpDir, name); + fs.mkdirSync(subDir); + fs.mkdirSync(path.join(subDir, '.git')); + } + assert.deepEqual(coreUtils.detectSubRepos(tmpDir), ['a-repo', 'm-repo', 'z-repo']); + }); + + test('adversarial: directory name with path-traversal-like characters', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + // Create a subdirectory that doesn't start with '.' and isn't node_modules + const subDir = path.join(tmpDir, 'normal-dir'); + fs.mkdirSync(subDir); + // No .git, so not a sub-repo + assert.deepEqual(coreUtils.detectSubRepos(tmpDir), []); + }); +}); + +// ─── pathExistsInternal ─────────────────────────────────────────────────────── + +describe('pathExistsInternal', () => { + let tmpDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + test('returns true for an existing file', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + const fp = path.join(tmpDir, 'file.txt'); + fs.writeFileSync(fp, 'hello'); + assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, 'file.txt'), true); + }); + + test('returns true for an existing directory', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + const subDir = path.join(tmpDir, 'subdir'); + fs.mkdirSync(subDir); + assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, 'subdir'), true); + }); + + test('returns false for a non-existent path', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, 'nope.txt'), false); + }); + + test('handles absolute targetPath', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + assert.strictEqual(coreUtils.pathExistsInternal(tmpDir, tmpDir), true); + }); + + test('adversarial: path traversal attempt returns false (no such file)', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + // Traversal resolves via path.join — no crash, just correct false/true + const result = coreUtils.pathExistsInternal(tmpDir, '../nonexistent'); + assert.strictEqual(typeof result, 'boolean'); + }); +}); + +// ─── generateSlugInternal ───────────────────────────────────────────────────── + +describe('generateSlugInternal', () => { + test('null → null', () => { + assert.strictEqual(coreUtils.generateSlugInternal(null), null); + }); + + test('undefined → null', () => { + assert.strictEqual(coreUtils.generateSlugInternal(undefined), null); + }); + + test('empty string → null', () => { + assert.strictEqual(coreUtils.generateSlugInternal(''), null); + }); + + test('lowercases and replaces non-alphanumeric with hyphens', () => { + assert.strictEqual(coreUtils.generateSlugInternal('Hello World!'), 'hello-world'); + }); + + test('strips leading and trailing hyphens', () => { + assert.strictEqual(coreUtils.generateSlugInternal(' Hello '), 'hello'); + }); + + test('truncates at 60 characters', () => { + const long = 'a'.repeat(100); + const result = coreUtils.generateSlugInternal(long); + assert.ok(result !== null && result.length <= 60); + }); + + test('unicode characters are replaced with hyphens', () => { + const result = coreUtils.generateSlugInternal('中文phase'); + assert.ok(typeof result === 'string'); + assert.ok(!result.includes('中')); + }); + + test('preserves numbers in slug', () => { + assert.strictEqual(coreUtils.generateSlugInternal('Phase 42 Done'), 'phase-42-done'); + }); +}); + +// ─── filterPlanFiles ────────────────────────────────────────────────────────── + +describe('filterPlanFiles', () => { + test('returns only PLAN.md and *-PLAN.md files', () => { + const files = ['PLAN.md', '01-PLAN.md', 'SUMMARY.md', 'README.md', 'foo-PLAN.md']; + assert.deepEqual(coreUtils.filterPlanFiles(files), ['PLAN.md', '01-PLAN.md', 'foo-PLAN.md']); + }); + + test('empty array → empty array', () => { + assert.deepEqual(coreUtils.filterPlanFiles([]), []); + }); + + test('no matching files → empty array', () => { + assert.deepEqual(coreUtils.filterPlanFiles(['SUMMARY.md', 'CONTEXT.md']), []); + }); + + test('case-sensitive: plan.md is not matched', () => { + assert.deepEqual(coreUtils.filterPlanFiles(['plan.md', 'Plan.md']), []); + }); +}); + +// ─── filterSummaryFiles ─────────────────────────────────────────────────────── + +describe('filterSummaryFiles', () => { + test('returns only SUMMARY.md and *-SUMMARY.md files', () => { + const files = ['SUMMARY.md', '01-SUMMARY.md', 'PLAN.md', 'foo-SUMMARY.md']; + assert.deepEqual(coreUtils.filterSummaryFiles(files), ['SUMMARY.md', '01-SUMMARY.md', 'foo-SUMMARY.md']); + }); + + test('empty array → empty array', () => { + assert.deepEqual(coreUtils.filterSummaryFiles([]), []); + }); + + test('no matching files → empty array', () => { + assert.deepEqual(coreUtils.filterSummaryFiles(['PLAN.md', 'CONTEXT.md']), []); + }); +}); + +// ─── readSubdirectories ─────────────────────────────────────────────────────── + +describe('readSubdirectories', () => { + let tmpDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + test('returns [] for non-existent directory', () => { + assert.deepEqual(coreUtils.readSubdirectories('/nonexistent-xyz-' + Date.now()), []); + }); + + test('returns [] for empty directory', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + assert.deepEqual(coreUtils.readSubdirectories(tmpDir), []); + }); + + test('returns only directory names, not files', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.mkdirSync(path.join(tmpDir, 'subdir')); + fs.writeFileSync(path.join(tmpDir, 'file.txt'), ''); + const result = coreUtils.readSubdirectories(tmpDir); + assert.deepEqual(result, ['subdir']); + }); + + test('sort=false returns dirs in filesystem order', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.mkdirSync(path.join(tmpDir, '02-phase')); + fs.mkdirSync(path.join(tmpDir, '01-phase')); + const result = coreUtils.readSubdirectories(tmpDir, false); + assert.strictEqual(result.length, 2); + }); + + test('sort=true orders by comparePhaseNum', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + for (const name of ['10-phase', '02-phase', '01-phase']) { + fs.mkdirSync(path.join(tmpDir, name)); + } + const result = coreUtils.readSubdirectories(tmpDir, true); + assert.deepEqual(result, ['01-phase', '02-phase', '10-phase']); + }); + + test('sort=true handles decimal phase ids correctly', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + for (const name of ['01.2-phase', '01.10-phase', '01.1-phase']) { + fs.mkdirSync(path.join(tmpDir, name)); + } + const result = coreUtils.readSubdirectories(tmpDir, true); + // Decimal ordering: 01.1 < 01.2 < 01.10 + assert.deepEqual(result, ['01.1-phase', '01.2-phase', '01.10-phase']); + }); +}); + +// ─── getPhaseFileStats ──────────────────────────────────────────────────────── + +describe('getPhaseFileStats', () => { + let tmpDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + test('returns empty arrays and false flags for empty directory', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + const stats = coreUtils.getPhaseFileStats(tmpDir); + assert.deepEqual(stats.plans, []); + assert.deepEqual(stats.summaries, []); + assert.strictEqual(stats.hasResearch, false); + assert.strictEqual(stats.hasContext, false); + assert.strictEqual(stats.hasVerification, false); + assert.strictEqual(stats.hasReviews, false); + }); + + test('detects PLAN.md files', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.writeFileSync(path.join(tmpDir, 'PLAN.md'), ''); + fs.writeFileSync(path.join(tmpDir, '01-PLAN.md'), ''); + const stats = coreUtils.getPhaseFileStats(tmpDir); + assert.deepEqual(stats.plans.sort(), ['01-PLAN.md', 'PLAN.md']); + }); + + test('detects SUMMARY.md files', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.writeFileSync(path.join(tmpDir, 'SUMMARY.md'), ''); + const stats = coreUtils.getPhaseFileStats(tmpDir); + assert.deepEqual(stats.summaries, ['SUMMARY.md']); + }); + + test('detects RESEARCH.md', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.writeFileSync(path.join(tmpDir, 'RESEARCH.md'), ''); + const stats = coreUtils.getPhaseFileStats(tmpDir); + assert.strictEqual(stats.hasResearch, true); + }); + + test('detects *-RESEARCH.md', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.writeFileSync(path.join(tmpDir, 'feature-RESEARCH.md'), ''); + const stats = coreUtils.getPhaseFileStats(tmpDir); + assert.strictEqual(stats.hasResearch, true); + }); + + test('detects VERIFICATION.md', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.writeFileSync(path.join(tmpDir, 'VERIFICATION.md'), ''); + const stats = coreUtils.getPhaseFileStats(tmpDir); + assert.strictEqual(stats.hasVerification, true); + }); + + test('detects REVIEWS.md', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.writeFileSync(path.join(tmpDir, 'REVIEWS.md'), ''); + const stats = coreUtils.getPhaseFileStats(tmpDir); + assert.strictEqual(stats.hasReviews, true); + }); + + test('detects CONTEXT.md via findContextMdIn', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-')); + fs.writeFileSync(path.join(tmpDir, 'CONTEXT.md'), ''); + const stats = coreUtils.getPhaseFileStats(tmpDir); + assert.strictEqual(stats.hasContext, true); + }); +}); + +// ─── extractOneLinerFromBody ────────────────────────────────────────────────── + +describe('extractOneLinerFromBody', () => { + test('null → null', () => { + assert.strictEqual(coreUtils.extractOneLinerFromBody(null), null); + }); + + test('undefined → null', () => { + assert.strictEqual(coreUtils.extractOneLinerFromBody(undefined), null); + }); + + test('empty string → null', () => { + assert.strictEqual(coreUtils.extractOneLinerFromBody(''), null); + }); + + test('extracts bold text after a heading as one-liner', () => { + const content = '# Phase Title\n\n**Implement the feature**\n\nMore details here.\n'; + assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Implement the feature'); + }); + + test('returns null when no bold text after heading', () => { + const content = '# Phase Title\n\nSome prose without bold.\n'; + assert.strictEqual(coreUtils.extractOneLinerFromBody(content), null); + }); + + test('strips frontmatter before searching', () => { + const content = '---\nstatus: done\n---\n# Title\n\n**One liner here**\n'; + assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'One liner here'); + }); + + test('when bold ends with colon, returns text after the bold', () => { + const content = '# Title\n\n**Objective:** Complete the work\n'; + assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Complete the work'); + }); + + test('CRLF line endings are normalized', () => { + const content = '# Title\r\n\r\n**Bold line**\r\nmore\r\n'; + assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Bold line'); + }); + + test('adversarial: unicode in bold text', () => { + const content = '# Title\n\n**中文 one-liner**\n\nMore.\n'; + assert.strictEqual(coreUtils.extractOneLinerFromBody(content), '中文 one-liner'); + }); +}); + +// ─── timeAgo ───────────────────────────────────────────────────────────────── + +describe('timeAgo', () => { + function daysAgo(n) { + return new Date(Date.now() - n * 24 * 60 * 60 * 1000); + } + function minutesAgo(n) { + return new Date(Date.now() - n * 60 * 1000); + } + function hoursAgo(n) { + return new Date(Date.now() - n * 60 * 60 * 1000); + } + function secondsAgo(n) { + return new Date(Date.now() - n * 1000); + } + + test('"just now" for < 5 seconds', () => { + assert.strictEqual(coreUtils.timeAgo(secondsAgo(2)), 'just now'); + }); + + test('"X seconds ago" for < 60 seconds', () => { + const result = coreUtils.timeAgo(secondsAgo(30)); + assert.ok(result.endsWith('seconds ago'), `Expected "X seconds ago", got: ${result}`); + }); + + test('"1 minute ago" for ~1 minute', () => { + assert.strictEqual(coreUtils.timeAgo(minutesAgo(1)), '1 minute ago'); + }); + + test('"X minutes ago" for < 60 minutes', () => { + const result = coreUtils.timeAgo(minutesAgo(30)); + assert.ok(result.endsWith('minutes ago'), `Expected "X minutes ago", got: ${result}`); + }); + + test('"1 hour ago" for ~1 hour', () => { + assert.strictEqual(coreUtils.timeAgo(hoursAgo(1)), '1 hour ago'); + }); + + test('"X hours ago" for < 24 hours', () => { + const result = coreUtils.timeAgo(hoursAgo(10)); + assert.ok(result.endsWith('hours ago'), `Expected "X hours ago", got: ${result}`); + }); + + test('"1 day ago" for ~1 day', () => { + assert.strictEqual(coreUtils.timeAgo(daysAgo(1)), '1 day ago'); + }); + + test('"X days ago" for < 30 days', () => { + const result = coreUtils.timeAgo(daysAgo(15)); + assert.ok(result.endsWith('days ago'), `Expected "X days ago", got: ${result}`); + }); + + test('"1 month ago" for ~30 days', () => { + assert.strictEqual(coreUtils.timeAgo(daysAgo(30)), '1 month ago'); + }); + + test('"X months ago" for < 12 months', () => { + const result = coreUtils.timeAgo(daysAgo(180)); + assert.ok(result.endsWith('months ago'), `Expected "X months ago", got: ${result}`); + }); + + test('"1 year ago" for ~365 days', () => { + assert.strictEqual(coreUtils.timeAgo(daysAgo(365)), '1 year ago'); + }); + + test('"X years ago" for multiple years', () => { + const result = coreUtils.timeAgo(daysAgo(730)); + assert.ok(result.endsWith('years ago'), `Expected "X years ago", got: ${result}`); + }); +}); + +// ─── extractCanonicalPlanId ─────────────────────────────────────────────────── + +describe('extractCanonicalPlanId', () => { + test('strips -PLAN.md suffix and returns basename', () => { + // '01-feature-PLAN.md' → base = '01-feature', no two adjacent phase tokens + assert.strictEqual(coreUtils.extractCanonicalPlanId('01-feature-PLAN.md'), '01-feature'); + }); + + test('strips -SUMMARY.md suffix', () => { + assert.strictEqual(coreUtils.extractCanonicalPlanId('01-SUMMARY.md'), '01'); + }); + + test('strips .md suffix for plain md file', () => { + assert.strictEqual(coreUtils.extractCanonicalPlanId('01.md'), '01'); + }); + + test('returns base when no phase token found', () => { + assert.strictEqual(coreUtils.extractCanonicalPlanId('no-phase-token.md'), 'no-phase-token'); + }); + + test('extracts canonical id with two adjacent phase tokens', () => { + // e.g. phase 01 plan 02: filename = "01-02-PLAN.md" + const result = coreUtils.extractCanonicalPlanId('01-02-PLAN.md'); + assert.strictEqual(result, '01-02'); + }); + + test('adversarial: decimal phase id tokens', () => { + // "01.1" matches the token regex (\d+[A-Z]?(\.\d+)*) + const result = coreUtils.extractCanonicalPlanId('01.1-PLAN.md'); + assert.ok(typeof result === 'string'); + }); + + test('adversarial: unicode filename returns some string', () => { + const result = coreUtils.extractCanonicalPlanId('中文-phase.md'); + assert.ok(typeof result === 'string'); + }); + + test('adversarial: path-traversal-like filename treated as literal', () => { + // extractCanonicalPlanId operates on a filename string (not a real path). + // The function does not sanitize slashes — it strips .md suffixes and + // attempts to find phase tokens. The result is a string (no crash). + const result = coreUtils.extractCanonicalPlanId('../../../etc/passwd'); + assert.ok(typeof result === 'string'); + assert.ok(result.length > 0); + }); +});