ADR-857 rollout phase 2c. Move 11 shared low-level utilities out of core.cts into a new leaf module src/core-utils.cts: POSIX path normalization (toPosixPath), filesystem scanning (detectSubRepos, readSubdirectories, getPhaseFileStats, pathExistsInternal), and small pure helpers (generateSlugInternal, extractOneLinerFromBody, filterPlanFiles, filterSummaryFiles, timeAgo, and the private extractCanonicalPlanId). core.cts re-exports the 10 public ones (callers unchanged); extractCanonicalPlanId stays private (exported from the leaf for core's fs-search functions). Cycle-free: core-utils depends only on Node built-ins + already-leafed modules (phase-id for comparePhaseNum, planning-workspace for findContextMdIn). This is the shared leaf that unblocks the phase-locator fs-search extraction (2d) — searchPhaseInDir/findPhaseInternal/getArchivedPhaseDirs can now take their utilities from a leaf instead of from core. New-CLI-module checklist done (.gitignore, eslint, INVENTORY 93->94 + row, manifest, ARCHITECTURE, CONTEXT.md "Core Utilities Module"). Adds tests/core-utils.test.cjs (88 tests: behavioral + shim-identity + adversarial). Gates: lint, code-review, security-review, codex adversarial-review (0 findings). Mac 4041 pass; clean-build docker 12935 pass, 0 fail. Closes #877 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"config.cjs",
|
||||
"configuration.cjs",
|
||||
"context-utilization.cjs",
|
||||
"core-utils.cjs",
|
||||
"core.cjs",
|
||||
"decisions.cjs",
|
||||
"docs.cjs",
|
||||
|
||||
@@ -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 `<decisions>` 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 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
201
src/core-utils.cts
Normal file
201
src/core-utils.cts
Normal file
@@ -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,
|
||||
};
|
||||
178
src/core.cts
178
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,
|
||||
|
||||
578
tests/core-utils.test.cjs
Normal file
578
tests/core-utils.test.cjs
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user