* refactor(#3183): route live-plan counting through scanPhasePlans scanPhasePlans becomes the sole owner of the live-plan derivation. Twenty-one independent re-derivations across seven modules now route through it, and scripts/lint-plan-count-drift.cjs reports zero, scanning the whole repo rather than an allowlist (ADR-3180 Decision 4a). The epic scoped this at three copies. A whole-repo guard found twenty-six sites across nine files, so Phase 1 absorbs every live-plan re-derivation and Phase 3 narrows to window plus sentinel enumeration. Two sites are exempt with a documented reason rather than a bare allowlist: audit.cts scans one quick task's own directory for a single completion record, and gsd2-import.cts reads a foreign GSD-2 tasks/ layout during a one-time import. Neither is a phase directory. scanPhasePlans gains allPlanFiles (pre-supersession) alongside planFiles so one owner answers both questions: verify.cts's numbering-gap check wants every plan on disk, its pairing check wants the live set. Both fields are additive. Highest-severity fix: cmdPhasePlanIndex, which feeds execute-phase wave scheduling, was scheduling status:superseded plans into waves and reporting zero plans for the post-#3139 nested layout. filterPlanFiles and filterSummaryFiles are deleted; getPhaseFileStats orphaned them and only their own tests still called them. New leaf module src/planning-scope.cts carries the frozen SCOPE discriminator, with its six-gate ripple closed: gitignore, inventory manifest, INVENTORY.md and the CONTEXT.md glossary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * docs(#3183): amend ADR-3180 for the Phase 1/3 boundary re-slice The contract held; the phase boundary did not. The whole-repo drift guard found 26 re-derivations across 9 files against the epic's estimate of 3, and cmdProgressRender re-derives both enumeration and plan counting on adjacent lines, so DW4 was unsatisfiable within Phase 1's original file scope. Records the amended scope, scanPhasePlans's new allPlanFiles field, findOrphanSummaries, the two documented exemptions, the re-derived Tier-2 table, and the describeNonCanonicalPlans trap for later phases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * fix(#3183): complete the canonical pairing rule and gate the naming diagnostic The remote runner went red with 13 deterministic failures on both lanes, and they were right: replacing verify.cts's canonicalPlanStem pairing with summaryCandidates dropped a case the bespoke rule covered. A plan carrying a descriptive slug after its id (68-01-scaffolding-PLAN.md) pairs with its canonical-stem summary (68-01-SUMMARY.md), and summaryCandidates generated no such candidate, so the plan read unsummarized. The fix is to complete the one rule rather than restore a second: summaryCandidates gains a canonical-id candidate, narrowed to fire only when an id pair was actually extracted. countMatchedSummaries, findUnsummarizedPlans and findOrphanSummaries all inherit it. The two-plans-one-summary collision behaviour of the original rule is preserved deliberately and documented in place. Second defect, independently root-caused while verifying: routing the #2893 naming diagnostic through scanPhasePlans exposed it to the loose /PLAN/i fallback, which is correct for counting and wrong for a naming check — a non-canonically-named file was accepted as a valid plan and the diagnostic went silent. cmdPhasesList, cmdFindPhase and cmdPhasePlanIndex now intersect with a strict isCanonicalPlanFile predicate before reporting names. Same class as the describeNonCanonicalPlans trap already recorded in ADR-3180: a question about file naming wants the physical, strictly-matched set; only a question about outstanding work wants the live set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * chore(#3183): register planning-scope.cjs in the eslint migration list tests/repo-invariants.test.cjs asserts every bin/lib/*.cjs is linted xor ignored per its ADR-457 migration state. The new planning-scope module closed five of the six .cts ripple gates - gitignore, inventory manifest, INVENTORY.md and the CONTEXT.md glossary - but not eslint, because that one is enforced by a test rather than by lint:ci, so the local pipeline stayed green while it was missing. Generated from src/planning-scope.cts, so the .cjs is ignored and the .cts is linted, matching every other migrated module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * fix(#3183): replace the plan-count drift detector with a literal tokenizer CodeQL reported 4 high-severity js/redos alerts on REGEX_LITERAL_MD_RE, the backtracking regex that finds "a regex literal mentioning PLAN/SUMMARY and an escaped \.md". Five review rounds found it had two defects, not one: - EXPONENTIAL, then CUBIC. Its "any char" atom `(?:\\.|[^/\r\n])` let a `\.` pair be consumed either as one escape or as two class characters, which is exponential backtracking: 27,464ms on `"/\.mdplan" + "\.".repeat(28) + "X"`. Excluding `\` from the class killed that but left a cubic path — 23ms at N=200, 172ms at N=400, 1362ms at N=800 on `"/" + "PLAN\.md".repeat(N)` with no closing `/`. This guard is the last stage of `npm run lint:ci`, which CI runs on fork pull requests, so a crafted src/*.cts could stall the job. - A DETECTION HOLE. A character class holding a bare, unescaped `/` — e.g. `/SUMMARY[^/]*\.md$/`, an ordinary path-excluding filter — terminated the literal at that `/`, so the scan never reached `\.md` and the guard missed it entirely. (Classes holding an ESCAPED `\/` were already matched; the tests cover those separately as parity, not as regressions.) Both defects have one root cause: regex-literal grammar — `\x` escapes, and `/` inside `[...]` not terminating — is not expressible in a backtracking regex. So the detector is now a tokenizer, not a regex. readRegexLiteralAt reads the literal at a given `/` in a single left-to-right pass with no backtracking, treating escapes as two-character units and suppressing the `/` terminator inside a character class. findRegexLiteralMdMatch restarts it at every `/` on the line, preserving the old "find anywhere" behaviour; MAX_REGEX_LITERAL_LEN (400) bounds each read — including the trailing-flag scan — which keeps the whole-line cost linear. Results: cubic shape flat at 0.06-0.39ms out to N=3200 (25KB), exponential shape 0.01ms at 28 reps and 0.00ms at 64, and the bare-`/` class shapes are now caught. Differential against the old regex over 28,474 lines (those matching FILENAME_TEST_RE but not PLAN_SUMMARY_LITERAL_RE, across src/tests/scripts/ gsd-core/bin/eslint-rules, excluding 265 lines with >6 backslashes on which the old regex hangs): 6 differences, all the tokenizer returning the fuller or newly-correct literal, 0 old-only misses. The `\.md` token stays case-insensitive, matching the `/i` the old regex carried. Also closes three holes in the same new file: - walk() tested entry.isFile(), false for a symlink, so a symlinked src/*.cts was silently unscanned — an evasion of a guard whose stated principle (ADR-3180 Decision 4a) is whole-repo discovery with no allowlist. It now resolves symlinks, but confined: file links must resolve inside the repo root, directory links inside the scanned dir itself. Every sibling drift guard in scripts/ uses the Dirent classification and never follows links, so following them unconfined would have made this the only linter able to read outside the tree — on fork PRs an arbitrary out-of-repo read whose matched fragments reach a public CI log. The narrower directory rule additionally stops `src/up -> ..` from sweeping the whole repo, and the skip list is now checked against resolved paths so `src/g -> ../.git` cannot reach .git/** or node_modules/**. Real paths are de-duplicated and files reported canonically, so a symlink alias cannot shift which FUNCTION_SCOPED_EXEMPTIONS key applies. - Both the reported fragment and the reported FILE PATH are attacker- controlled source text written straight to a CI log, and git permits control bytes in a filename. Both are now escaped — C0/C1/DEL plus the bidi and zero-width controls — so a crafted literal or filename cannot recolour the log, overwrite a line with CR, or fabricate a line that looks like this guard's own success output. Regression coverage in tests/plan-count-single-owner.test.cjs: a child-process probe over both pathological shapes (catastrophic backtracking is synchronous and would freeze the suite rather than fail one test), the bare-`/` class shapes verified to fail against the parent-commit blob, root-confinement tests covering the outside-file, outside-directory, cycle, broken-link and duplicate cases, direct isInsideRoot coverage including the sibling-prefix case that a bare startsWith would let through, sanitizeForReport coverage, and limit-1/limit/limit+1 coverage of MAX_REGEX_LITERAL_LEN derived from the exported constant. The earlier structural assertion was dropped — it checked for the substring `[^/`, which respelling the class as `[^\r\n/]` defeats while staying exponential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * chore(#3183): backfill changeset PR number Restores b77931869, which a force-push during the ReDoS remediation dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
/**
|
|
* Phase Locator — Phase-directory search and location
|
|
*
|
|
* ADR-857 rollout phase 2d: extracted from core.cts (issue #881).
|
|
* Owns active-phase discovery against the `.planning/phases/` tree
|
|
* (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir
|
|
* enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against
|
|
* the filesystem. Behaviour is preserved byte-for-behaviour from the prior
|
|
* location; only the module boundary moved. The core.cjs re-export spine
|
|
* was retired in epic #1267; callers import phase-locator helpers directly.
|
|
*
|
|
* Dependencies (leaf modules only — no loadConfig):
|
|
* - node:fs / node:path (stdlib)
|
|
* - ./phase-id.cjs (normalizePhaseName, phaseTokenMatches, extractPhaseToken)
|
|
* - ./core-utils.cjs (readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath)
|
|
* - ./planning-workspace.cjs (planningDir)
|
|
*/
|
|
|
|
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 { normalizePhaseName, phaseTokenMatches, extractPhaseToken } = phaseIdModule;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import coreUtilsModule = require('./core-utils.cjs');
|
|
const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath, findUnsummarizedPlans } = coreUtilsModule;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
const { planningDir } = planningWorkspace;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import frontmatterModule = require('./frontmatter.cjs');
|
|
const { extractFrontmatter } = frontmatterModule;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planDependencyGraphModule = require('./plan-dependency-graph.cjs');
|
|
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphModule;
|
|
|
|
// ─── Phase search types ───────────────────────────────────────────────────────
|
|
|
|
interface PhaseSearchResult {
|
|
found: boolean;
|
|
directory: string;
|
|
phase_number: string;
|
|
phase_name: string | null;
|
|
phase_slug: string | null;
|
|
plans: string[];
|
|
summaries: string[];
|
|
incomplete_plans: string[];
|
|
has_research: boolean;
|
|
has_context: boolean;
|
|
has_verification: boolean;
|
|
has_reviews: boolean;
|
|
archived?: string;
|
|
ambiguous_matches?: string[];
|
|
/**
|
|
* #2830: plan filenames (from `plans`) whose own SUMMARY declares
|
|
* `status: halted` — a designed stop, not an ordinary completion.
|
|
*/
|
|
halted_plans: string[];
|
|
/**
|
|
* #2830: plan filename -> the halted plan id(s) (canonical, e.g. "01-02")
|
|
* transitively blocking it, for every entry in `incomplete_plans` that is
|
|
* blocked by an upstream halt. A plan filename absent from this map is not
|
|
* blocked (either not incomplete, or incomplete with no halted upstream).
|
|
*/
|
|
blocked_by: Record<string, string[]>;
|
|
/**
|
|
* #2830: the runnable-only view — `incomplete_plans` filtered to exclude
|
|
* anything present as a key in `blocked_by`. `incomplete_plans` itself
|
|
* keeps its pre-#2830 meaning ("no matching SUMMARY yet") unchanged.
|
|
*/
|
|
runnable_plans: string[];
|
|
}
|
|
|
|
/**
|
|
* #2830: parse a plan file's `depends_on` frontmatter. Returns [] — never
|
|
* throws — on a missing/unreadable/malformed plan or absent field, matching
|
|
* this primitive's existing fail-safe posture (a plan directory this
|
|
* primitive can otherwise read must never throw here).
|
|
*/
|
|
function parsePlanDependsOn(phaseDir: string, planFile: string): string[] {
|
|
try {
|
|
const planPath = path.join(phaseDir, planFile);
|
|
const content = fs.readFileSync(planPath, 'utf-8');
|
|
const fm = extractFrontmatter(content, planPath);
|
|
const fmDeps = fm['depends_on'];
|
|
if (Array.isArray(fmDeps)) return fmDeps.map(String);
|
|
if (typeof fmDeps === 'string' && fmDeps.trim() !== '') return [fmDeps];
|
|
return [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
interface ArchivedPhaseDir {
|
|
name: string;
|
|
milestone: string;
|
|
basePath: string;
|
|
fullPath: string;
|
|
}
|
|
|
|
interface ArchiveVersionDir {
|
|
version: string;
|
|
archivePath: string;
|
|
}
|
|
|
|
// ─── Phase search helpers ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* #2855: single source of truth for resolving and enumerating a project's
|
|
* (or, when a workstream is active, that workstream's OWN) archived-milestone
|
|
* directories — `<planningDir(cwd)>/milestones/vX.Y-phases/`. Both
|
|
* `findPhaseInternal`'s archive fallback and `getArchivedPhaseDirs` used to
|
|
* carry independent copies of this resolve-then-enumerate logic, which is
|
|
* exactly the shape that let the original #2855 bug (hardcoded root path)
|
|
* exist in one copy and not the other. Sharing this seam means a future
|
|
* change to how the archive tree is located only needs to happen once.
|
|
* Most-recent-milestone-first order (reverse-sorted directory names).
|
|
* Never throws: an absent/unreadable milestones/ dir yields [].
|
|
*/
|
|
function listArchiveVersionDirs(cwd: string): ArchiveVersionDir[] {
|
|
const milestonesDir = path.join(planningDir(cwd), 'milestones');
|
|
if (!fs.existsSync(milestonesDir)) return [];
|
|
|
|
try {
|
|
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
|
return milestoneEntries
|
|
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
|
.map(e => e.name)
|
|
.sort()
|
|
.reverse()
|
|
.map(archiveName => ({
|
|
version: archiveName.match(/^(v[\d.]+)-phases$/)![1],
|
|
archivePath: path.join(milestonesDir, archiveName),
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
|
|
try {
|
|
const dirs = readSubdirectories(baseDir, true);
|
|
const matches = dirs.filter(d => phaseTokenMatches(d, normalized));
|
|
if (matches.length === 0) return null;
|
|
|
|
// #2237: fail loud when multiple directories match the same bare phase
|
|
// number — this happens when unrelated projects share a .planning/phases/
|
|
// tree. Silently taking the first match risks cross-project file writes.
|
|
if (matches.length > 1) {
|
|
return {
|
|
found: false,
|
|
directory: '',
|
|
phase_number: normalized,
|
|
phase_name: null,
|
|
phase_slug: null,
|
|
plans: [],
|
|
summaries: [],
|
|
incomplete_plans: [],
|
|
has_research: false,
|
|
has_context: false,
|
|
has_verification: false,
|
|
has_reviews: false,
|
|
ambiguous_matches: matches,
|
|
halted_plans: [],
|
|
blocked_by: {},
|
|
runnable_plans: [],
|
|
};
|
|
}
|
|
|
|
const match = matches[0];
|
|
|
|
const phaseToken = extractPhaseToken(match);
|
|
const phaseNumber = phaseToken || normalized;
|
|
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
|
|
const phaseName = afterToken || null;
|
|
const phaseDir = path.join(baseDir, match);
|
|
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
|
|
const plans = unsortedPlans.sort();
|
|
const summaries = unsortedSummaries.sort();
|
|
|
|
// #3183 (ADR-3180 Decision 2): the summary→plan pairing used to be a
|
|
// bespoke rule local to this function (a third pairing rule alongside
|
|
// scanPhasePlans's completion check and countMatchedSummaries). Routed
|
|
// through the canonical core-utils.findUnsummarizedPlans instead, which
|
|
// shares its `summaryCandidates` matching rule with countMatchedSummaries
|
|
// so the count and this named list can never disagree.
|
|
const incompletePlans = findUnsummarizedPlans(plans, summaries);
|
|
|
|
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
|
|
// its actual summary filename. Shared builder (also used by phase.cts's
|
|
// cmdPhasePlanIndex) so the two can never disagree about which summary
|
|
// belongs to which plan.
|
|
const summaryFileByPlanId = buildSummaryFileIndex(summaries, extractCanonicalPlanId);
|
|
|
|
// #2830: this primitive previously never parsed depends_on at all — see
|
|
// src/plan-dependency-graph.cts's file header. Build the same
|
|
// PlanHaltNode[] shape phase.cts's cmdPhasePlanIndex builds (id resolution
|
|
// mirrors its planMap/canonicalToId pattern) and hand it to the ONE
|
|
// shared halt-propagation traversal so this reader and the wave-grouping
|
|
// reader can never diverge on the halt rule again.
|
|
const planIds = plans.map(p => p.replace('-PLAN.md', '').replace('PLAN.md', ''));
|
|
const planIdByLower = new Map(planIds.map(id => [id.toLowerCase(), id]));
|
|
const canonicalToPlanId = new Map(
|
|
plans.map((p, i) => [extractCanonicalPlanId(p).toLowerCase(), planIds[i]]),
|
|
);
|
|
|
|
const haltNodes = plans.map((p, i) => {
|
|
const planId = planIds[i];
|
|
const canonical = extractCanonicalPlanId(p);
|
|
const summaryFile = summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(canonical);
|
|
const halted = summaryFile !== undefined && isSummaryFileHalted(path.join(phaseDir, summaryFile));
|
|
const resolvedDependsOn = parsePlanDependsOn(phaseDir, p)
|
|
.map((dep) => {
|
|
const lower = dep.toLowerCase();
|
|
return planIdByLower.get(lower) ?? canonicalToPlanId.get(lower) ?? null;
|
|
})
|
|
.filter((id): id is string => id !== null);
|
|
return { id: planId, resolvedDependsOn, halted };
|
|
});
|
|
const { blockedBy } = computeHaltPropagation(haltNodes);
|
|
|
|
const haltedPlans = plans.filter((_, i) => haltNodes[i].halted);
|
|
const incompletePlanSet = new Set(incompletePlans);
|
|
const blockedByFiles: Record<string, string[]> = {};
|
|
const runnablePlans: string[] = [];
|
|
for (let i = 0; i < plans.length; i++) {
|
|
const p = plans[i];
|
|
if (!incompletePlanSet.has(p)) continue;
|
|
const causes = blockedBy.get(planIds[i]) ?? [];
|
|
if (causes.length > 0) {
|
|
blockedByFiles[p] = causes;
|
|
} else {
|
|
runnablePlans.push(p);
|
|
}
|
|
}
|
|
|
|
return {
|
|
found: true,
|
|
directory: toPosixPath(path.join(relBase, match)),
|
|
phase_number: phaseNumber,
|
|
phase_name: phaseName,
|
|
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
|
|
plans,
|
|
summaries,
|
|
incomplete_plans: incompletePlans,
|
|
has_research: hasResearch,
|
|
has_context: hasContext,
|
|
has_verification: hasVerification,
|
|
has_reviews: hasReviews,
|
|
halted_plans: haltedPlans,
|
|
blocked_by: blockedByFiles,
|
|
runnable_plans: runnablePlans,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
|
|
if (!phase) return null;
|
|
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
const normalized = normalizePhaseName(phase);
|
|
|
|
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
|
|
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
|
|
if (current) return current;
|
|
|
|
// #2855: scope the archived-milestone fallback to the SAME workstream as the
|
|
// active-phase search above (planningDir(cwd) resolves GSD_WORKSTREAM/GSD_PROJECT
|
|
// the identical way both places), not the hardcoded project-root tree. Archived
|
|
// phases genuinely live under a workstream's own `.planning/workstreams/<ws>/
|
|
// milestones/` — that is where archivePhaseDirectories (milestone.cts) writes
|
|
// them via the same planningDir(cwd) resolution. Hardcoding root here let a
|
|
// pending workstream phase resolve to an unrelated workstream's (or a flat-mode
|
|
// project's) archived phase that merely shares a phase number. Shared with
|
|
// getArchivedPhaseDirs via listArchiveVersionDirs (see its doc comment).
|
|
for (const { version, archivePath } of listArchiveVersionDirs(cwd)) {
|
|
const relBase = toPosixPath(path.relative(cwd, archivePath));
|
|
const result = searchPhaseInDir(archivePath, relBase, normalized);
|
|
if (result) {
|
|
result.archived = version;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
|
|
// #2855: same workstream-scoped resolution as findPhaseInternal above, via
|
|
// the shared listArchiveVersionDirs helper. `phase.list --include-archived`
|
|
// (the primary non-init consumer) must not leak a different workstream's
|
|
// archive either.
|
|
const results: ArchivedPhaseDir[] = [];
|
|
|
|
for (const { version, archivePath } of listArchiveVersionDirs(cwd)) {
|
|
const dirs = readSubdirectories(archivePath, true);
|
|
|
|
for (const dir of dirs) {
|
|
results.push({
|
|
name: dir,
|
|
milestone: version,
|
|
basePath: toPosixPath(path.relative(cwd, archivePath)),
|
|
fullPath: path.join(archivePath, dir),
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
export = {
|
|
searchPhaseInDir,
|
|
findPhaseInternal,
|
|
getArchivedPhaseDirs,
|
|
};
|