@outcomeeng/spx 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -234,7 +234,7 @@ async function runVerifyPipeline(filePath, productDir) {
234
234
  return { lines: [], exitCode: 0, verdict: verdict.header?.verdict };
235
235
  }
236
236
 
237
- // src/domains/audit/cli.ts
237
+ // src/commands/audit/verify.ts
238
238
  async function runVerifyCommand(filePath, productDir, writeLine) {
239
239
  const result = await runVerifyPipeline(filePath, productDir);
240
240
  if (result.exitCode === 0) {
@@ -246,6 +246,8 @@ async function runVerifyCommand(filePath, productDir, writeLine) {
246
246
  }
247
247
  return result.exitCode;
248
248
  }
249
+
250
+ // src/interfaces/cli/audit.ts
249
251
  var auditDomain = {
250
252
  name: "audit",
251
253
  description: "Audit verdict verification",
@@ -272,7 +274,7 @@ async function initCommand(options = {}) {
272
274
  if (!exists) {
273
275
  await execa(
274
276
  "claude",
275
- ["plugin", "marketplace", "add", "outcomeeng/claude"],
277
+ ["plugin", "marketplace", "add", "outcomeeng/plugins"],
276
278
  { cwd }
277
279
  );
278
280
  return "\u2713 outcomeeng marketplace installed successfully\n\nRun 'claude plugin marketplace list' to view all marketplaces.";
@@ -839,7 +841,7 @@ Searched for: **/.claude/settings.local.json`;
839
841
  return formatReport(result, previewOnly, globalSettingsPath, outputFile);
840
842
  }
841
843
 
842
- // src/domains/claude/index.ts
844
+ // src/interfaces/cli/claude.ts
843
845
  function registerClaudeCommands(claudeCmd) {
844
846
  claudeCmd.command("init").description("Initialize or update outcomeeng marketplace plugin").action(async () => {
845
847
  try {
@@ -2729,7 +2731,7 @@ function resolveProductDir(cwd = process.cwd()) {
2729
2731
  };
2730
2732
  }
2731
2733
 
2732
- // src/domains/config/index.ts
2734
+ // src/interfaces/cli/config.ts
2733
2735
  function buildDefaultDeps() {
2734
2736
  return {
2735
2737
  resolveConfig,
@@ -2778,7 +2780,7 @@ var configDomain = {
2778
2780
  };
2779
2781
 
2780
2782
  // src/commands/session/archive.ts
2781
- import { mkdir, readFile as readFile3, rename, stat } from "fs/promises";
2783
+ import { mkdir, rename, stat } from "fs/promises";
2782
2784
  import { dirname as dirname2, join as join4 } from "path";
2783
2785
 
2784
2786
  // src/domains/session/archive.ts
@@ -2892,10 +2894,12 @@ var SessionInvalidNextStepError = class extends SessionError {
2892
2894
  this.name = "SessionInvalidNextStepError";
2893
2895
  }
2894
2896
  };
2895
- var SessionInvalidResultError = class extends SessionError {
2896
- constructor(sessionId) {
2897
- super(`Invalid session result for ${sessionId}: result must be a non-empty string before archive.`);
2898
- this.name = "SessionInvalidResultError";
2897
+ var SessionHandoffBaseError = class extends SessionError {
2898
+ constructor() {
2899
+ super(
2900
+ "Cannot create a handoff session from this git work context. Run handoff from the root worktree, or from a linked worktree with a clean working tree detached at the tip of origin/<default branch>."
2901
+ );
2902
+ this.name = "SessionHandoffBaseError";
2899
2903
  }
2900
2904
  };
2901
2905
  var SessionNotClaimedError = class extends SessionError {
@@ -2913,179 +2917,27 @@ var NoSessionsAvailableError = class extends SessionError {
2913
2917
  this.name = "NoSessionsAvailableError";
2914
2918
  }
2915
2919
  };
2920
+ var SessionLegacyFrontmatterInputError = class extends SessionError {
2921
+ constructor() {
2922
+ super(
2923
+ `Invalid handoff input: stdin opens with the YAML-frontmatter delimiter \`---\`. Use the JSON-prefix wire format: a single JSON object holding caller-supplied fields followed by the body bytes verbatim. Example:
2916
2924
 
2917
- // src/domains/session/list.ts
2918
- import { parse as parseYaml2 } from "yaml";
2919
-
2920
- // src/domains/session/timestamp.ts
2921
- var SESSION_ID_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})$/;
2922
- var SESSION_ID_SEPARATOR = "_";
2923
- function generateSessionId(options = {}) {
2924
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
2925
- const date = now();
2926
- const year = date.getFullYear();
2927
- const month = String(date.getMonth() + 1).padStart(2, "0");
2928
- const day = String(date.getDate()).padStart(2, "0");
2929
- const hours = String(date.getHours()).padStart(2, "0");
2930
- const minutes = String(date.getMinutes()).padStart(2, "0");
2931
- const seconds = String(date.getSeconds()).padStart(2, "0");
2932
- return `${year}-${month}-${day}${SESSION_ID_SEPARATOR}${hours}-${minutes}-${seconds}`;
2933
- }
2934
- function parseSessionId(id) {
2935
- const match = SESSION_ID_PATTERN.exec(id);
2936
- if (!match) {
2937
- return null;
2925
+ printf '%s\\n' '{"priority":"high","goal":"...","next_step":"..."}' '# Body' | spx session handoff`
2926
+ );
2927
+ this.name = "SessionLegacyFrontmatterInputError";
2938
2928
  }
2939
- const [, yearStr, monthStr, dayStr, hoursStr, minutesStr, secondsStr] = match;
2940
- const year = parseInt(yearStr, 10);
2941
- const month = parseInt(monthStr, 10) - 1;
2942
- const day = parseInt(dayStr, 10);
2943
- const hours = parseInt(hoursStr, 10);
2944
- const minutes = parseInt(minutesStr, 10);
2945
- const seconds = parseInt(secondsStr, 10);
2946
- if (month < 0 || month > 11) return null;
2947
- if (day < 1 || day > 31) return null;
2948
- if (hours < 0 || hours > 23) return null;
2949
- if (minutes < 0 || minutes > 59) return null;
2950
- if (seconds < 0 || seconds > 59) return null;
2951
- return new Date(year, month, day, hours, minutes, seconds);
2952
- }
2953
-
2954
- // src/domains/session/types.ts
2955
- var SESSION_PRIORITY = {
2956
- HIGH: "high",
2957
- MEDIUM: "medium",
2958
- LOW: "low"
2959
- };
2960
- var SESSION_STATUSES = ["todo", "doing", "archive"];
2961
- var DEFAULT_LIST_STATUSES = ["doing", "todo"];
2962
- var PRIORITY_ORDER = {
2963
- [SESSION_PRIORITY.HIGH]: 0,
2964
- [SESSION_PRIORITY.MEDIUM]: 1,
2965
- [SESSION_PRIORITY.LOW]: 2
2966
- };
2967
- var DEFAULT_PRIORITY = SESSION_PRIORITY.MEDIUM;
2968
- var SESSION_FRONT_MATTER = {
2969
- PRIORITY: "priority",
2970
- ID: "id",
2971
- BRANCH: "branch",
2972
- WORKTREE: "worktree",
2973
- GOAL: "goal",
2974
- NEXT_STEP: "next_step",
2975
- RESULT: "result",
2976
- CREATED_AT: "created_at",
2977
- AGENT_SESSION_ID: "agent_session_id",
2978
- SPECS: "specs",
2979
- FILES: "files"
2980
- };
2981
-
2982
- // src/domains/session/list.ts
2983
- var FRONT_MATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n?/;
2984
- var SESSION_PRIORITY_VALUES = Object.values(SESSION_PRIORITY);
2985
- var DEFAULT_SESSION_METADATA = {
2986
- priority: DEFAULT_PRIORITY,
2987
- branch: "",
2988
- worktree: "",
2989
- goal: "",
2990
- next_step: "",
2991
- result: "",
2992
- specs: [],
2993
- files: []
2994
2929
  };
2995
- function defaultSessionMetadata() {
2996
- return {
2997
- ...DEFAULT_SESSION_METADATA,
2998
- specs: [],
2999
- files: []
3000
- };
3001
- }
3002
- function isValidPriority(value) {
3003
- return typeof value === "string" && SESSION_PRIORITY_VALUES.some((priority) => priority === value);
3004
- }
3005
- function parseSessionMetadata(content) {
3006
- const match = FRONT_MATTER_PATTERN.exec(content);
3007
- if (!match) {
3008
- return defaultSessionMetadata();
3009
- }
3010
- try {
3011
- const parsed = parseYaml2(match[1]);
3012
- if (!parsed || typeof parsed !== "object") {
3013
- return defaultSessionMetadata();
3014
- }
3015
- const rawPriority = parsed[SESSION_FRONT_MATTER.PRIORITY];
3016
- const priority = isValidPriority(rawPriority) ? rawPriority : DEFAULT_PRIORITY;
3017
- const metadata = {
3018
- ...defaultSessionMetadata(),
3019
- priority
3020
- };
3021
- const id = parsed[SESSION_FRONT_MATTER.ID];
3022
- if (typeof id === "string") metadata.id = id;
3023
- const branch = parsed[SESSION_FRONT_MATTER.BRANCH];
3024
- metadata.branch = typeof branch === "string" ? branch : DEFAULT_SESSION_METADATA.branch;
3025
- const worktree = parsed[SESSION_FRONT_MATTER.WORKTREE];
3026
- metadata.worktree = typeof worktree === "string" ? worktree : DEFAULT_SESSION_METADATA.worktree;
3027
- const goal = parsed[SESSION_FRONT_MATTER.GOAL];
3028
- metadata.goal = typeof goal === "string" ? goal : DEFAULT_SESSION_METADATA.goal;
3029
- const nextStep = parsed[SESSION_FRONT_MATTER.NEXT_STEP];
3030
- metadata.next_step = typeof nextStep === "string" ? nextStep : DEFAULT_SESSION_METADATA.next_step;
3031
- const result = parsed[SESSION_FRONT_MATTER.RESULT];
3032
- metadata.result = typeof result === "string" ? result : DEFAULT_SESSION_METADATA.result;
3033
- const createdAt = parsed[SESSION_FRONT_MATTER.CREATED_AT];
3034
- if (typeof createdAt === "string") metadata.created_at = createdAt;
3035
- const agentSessionId = parsed[SESSION_FRONT_MATTER.AGENT_SESSION_ID];
3036
- if (typeof agentSessionId === "string") metadata.agent_session_id = agentSessionId;
3037
- const specs = parsed[SESSION_FRONT_MATTER.SPECS];
3038
- if (Array.isArray(specs)) {
3039
- metadata.specs = specs.filter((s) => typeof s === "string");
3040
- }
3041
- const files = parsed[SESSION_FRONT_MATTER.FILES];
3042
- if (Array.isArray(files)) {
3043
- metadata.files = files.filter((f) => typeof f === "string");
3044
- }
3045
- return metadata;
3046
- } catch {
3047
- return defaultSessionMetadata();
2930
+ var SessionInvalidJsonHeaderError = class extends SessionError {
2931
+ constructor(reason) {
2932
+ super(`Invalid JSON header for handoff: ${reason}`);
2933
+ this.name = "SessionInvalidJsonHeaderError";
3048
2934
  }
3049
- }
3050
- function sortSessions(sessions) {
3051
- return [...sessions].sort((a, b) => {
3052
- const priorityA = PRIORITY_ORDER[a.metadata.priority];
3053
- const priorityB = PRIORITY_ORDER[b.metadata.priority];
3054
- if (priorityA !== priorityB) {
3055
- return priorityA - priorityB;
3056
- }
3057
- const dateA = parseSessionId(a.id);
3058
- const dateB = parseSessionId(b.id);
3059
- if (!dateA && !dateB) return 0;
3060
- if (!dateA) return 1;
3061
- if (!dateB) return -1;
3062
- return dateB.getTime() - dateA.getTime();
3063
- });
3064
- }
2935
+ };
3065
2936
 
3066
2937
  // src/git/root.ts
3067
2938
  import { execa as execa2 } from "execa";
3068
2939
  import { dirname, isAbsolute, join as join3, relative as relative2, resolve as resolve3 } from "path";
3069
2940
 
3070
- // src/git/errors.ts
3071
- var SESSION_GIT_CONTEXT_ERROR_MESSAGE = {
3072
- BRANCH_UNAVAILABLE: "Cannot create a handoff session because the current Git branch could not be detected.",
3073
- EMPTY_BRANCH: "Cannot create a handoff session because Git returned an empty branch name.",
3074
- DETACHED_HEAD: "Cannot create a handoff session from a detached HEAD."
3075
- };
3076
- var SessionGitContextError = class extends Error {
3077
- constructor(message = SESSION_GIT_CONTEXT_ERROR_MESSAGE.BRANCH_UNAVAILABLE) {
3078
- super(message);
3079
- this.name = "SessionGitContextError";
3080
- }
3081
- };
3082
- var SessionDetachedHeadError = class extends SessionGitContextError {
3083
- constructor() {
3084
- super(SESSION_GIT_CONTEXT_ERROR_MESSAGE.DETACHED_HEAD);
3085
- this.name = "SessionDetachedHeadError";
3086
- }
3087
- };
3088
-
3089
2941
  // src/config/defaults.ts
3090
2942
  var DEFAULT_CONFIG = {
3091
2943
  sessions: {
@@ -3131,14 +2983,22 @@ var GIT_ROOT_COMMAND = {
3131
2983
  ABBREV_REF: "--abbrev-ref",
3132
2984
  GIT_COMMON_DIR: "--git-common-dir",
3133
2985
  HEAD: "HEAD",
3134
- SHOW_TOPLEVEL: "--show-toplevel"
2986
+ SHOW_TOPLEVEL: "--show-toplevel",
2987
+ SYMBOLIC_REF: "symbolic-ref",
2988
+ SHORT: "--short",
2989
+ ORIGIN_HEAD_REF: "refs/remotes/origin/HEAD",
2990
+ STATUS: "status",
2991
+ PORCELAIN: "--porcelain",
2992
+ PATH_FORMAT_ABSOLUTE: "--path-format=absolute"
3135
2993
  };
2994
+ var ORIGIN_REF_PREFIX = "origin/";
3136
2995
  var GIT_SHOW_TOPLEVEL_ARGS = [
3137
2996
  GIT_ROOT_COMMAND.REV_PARSE,
3138
2997
  GIT_ROOT_COMMAND.SHOW_TOPLEVEL
3139
2998
  ];
3140
2999
  var GIT_COMMON_DIR_ARGS = [
3141
3000
  GIT_ROOT_COMMAND.REV_PARSE,
3001
+ GIT_ROOT_COMMAND.PATH_FORMAT_ABSOLUTE,
3142
3002
  GIT_ROOT_COMMAND.GIT_COMMON_DIR
3143
3003
  ];
3144
3004
  var GIT_CURRENT_BRANCH_ARGS = [
@@ -3146,6 +3006,19 @@ var GIT_CURRENT_BRANCH_ARGS = [
3146
3006
  GIT_ROOT_COMMAND.ABBREV_REF,
3147
3007
  GIT_ROOT_COMMAND.HEAD
3148
3008
  ];
3009
+ var GIT_HEAD_SHA_ARGS = [
3010
+ GIT_ROOT_COMMAND.REV_PARSE,
3011
+ GIT_ROOT_COMMAND.HEAD
3012
+ ];
3013
+ var GIT_ORIGIN_HEAD_REF_ARGS = [
3014
+ GIT_ROOT_COMMAND.SYMBOLIC_REF,
3015
+ GIT_ROOT_COMMAND.SHORT,
3016
+ GIT_ROOT_COMMAND.ORIGIN_HEAD_REF
3017
+ ];
3018
+ var GIT_STATUS_PORCELAIN_ARGS = [
3019
+ GIT_ROOT_COMMAND.STATUS,
3020
+ GIT_ROOT_COMMAND.PORCELAIN
3021
+ ];
3149
3022
  async function detectWorktreeProductRoot(cwd = process.cwd(), deps = defaultDeps) {
3150
3023
  try {
3151
3024
  const result = await deps.execa(
@@ -3224,36 +3097,68 @@ function computeRelativeWorktreePath(commonDir, toplevel) {
3224
3097
  const worktreePath = relative2(commonDirProductRoot, toplevel);
3225
3098
  return worktreePath === "" ? "" : worktreePath;
3226
3099
  }
3227
- async function detectSessionWorkContext(cwd = process.cwd(), deps = defaultDeps) {
3228
- const branchResult = await deps.execa(
3100
+ async function resolveDefaultBranch(cwd = process.cwd(), deps = defaultDeps) {
3101
+ const result = await deps.execa(
3102
+ GIT_ROOT_COMMAND.EXECUTABLE,
3103
+ [...GIT_ORIGIN_HEAD_REF_ARGS],
3104
+ { cwd, reject: false }
3105
+ );
3106
+ if (result.exitCode !== 0) return null;
3107
+ const ref = extractStdout(result.stdout);
3108
+ if (!ref.startsWith(ORIGIN_REF_PREFIX)) return null;
3109
+ const branch = ref.slice(ORIGIN_REF_PREFIX.length);
3110
+ return branch.length === 0 ? null : branch;
3111
+ }
3112
+ async function getCurrentBranch(cwd = process.cwd(), deps = defaultDeps) {
3113
+ const result = await deps.execa(
3229
3114
  GIT_ROOT_COMMAND.EXECUTABLE,
3230
3115
  [...GIT_CURRENT_BRANCH_ARGS],
3231
3116
  { cwd, reject: false }
3232
3117
  );
3233
- const branch = extractStdout(branchResult.stdout);
3234
- if (branchResult.exitCode !== 0) {
3235
- throw new SessionGitContextError(SESSION_GIT_CONTEXT_ERROR_MESSAGE.BRANCH_UNAVAILABLE);
3236
- }
3237
- if (branch.length === 0) {
3238
- throw new SessionGitContextError(SESSION_GIT_CONTEXT_ERROR_MESSAGE.EMPTY_BRANCH);
3239
- }
3240
- if (branch === GIT_ROOT_COMMAND.HEAD) {
3241
- throw new SessionDetachedHeadError();
3242
- }
3243
- const toplevelResult = await deps.execa(
3118
+ if (result.exitCode !== 0) return null;
3119
+ const branch = extractStdout(result.stdout);
3120
+ if (branch.length === 0 || branch === GIT_ROOT_COMMAND.HEAD) return null;
3121
+ return branch;
3122
+ }
3123
+ async function getHeadSha(cwd = process.cwd(), deps = defaultDeps) {
3124
+ const result = await deps.execa(
3125
+ GIT_ROOT_COMMAND.EXECUTABLE,
3126
+ [...GIT_HEAD_SHA_ARGS],
3127
+ { cwd, reject: false }
3128
+ );
3129
+ if (result.exitCode !== 0) return null;
3130
+ const sha = extractStdout(result.stdout);
3131
+ return sha.length === 0 ? null : sha;
3132
+ }
3133
+ async function resolveRefSha(ref, cwd = process.cwd(), deps = defaultDeps) {
3134
+ const result = await deps.execa(
3244
3135
  GIT_ROOT_COMMAND.EXECUTABLE,
3245
- [...GIT_SHOW_TOPLEVEL_ARGS],
3136
+ [GIT_ROOT_COMMAND.REV_PARSE, ref],
3246
3137
  { cwd, reject: false }
3247
3138
  );
3248
- const commonDirResult = await deps.execa(
3139
+ if (result.exitCode !== 0) return null;
3140
+ const sha = extractStdout(result.stdout);
3141
+ return sha.length === 0 ? null : sha;
3142
+ }
3143
+ async function isWorkingTreeClean(cwd = process.cwd(), deps = defaultDeps) {
3144
+ const result = await deps.execa(
3249
3145
  GIT_ROOT_COMMAND.EXECUTABLE,
3250
- [...GIT_COMMON_DIR_ARGS],
3146
+ [...GIT_STATUS_PORCELAIN_ARGS],
3251
3147
  { cwd, reject: false }
3252
3148
  );
3149
+ if (result.exitCode !== 0) return false;
3150
+ return extractStdout(result.stdout).length === 0;
3151
+ }
3152
+ async function isRootWorktree(cwd = process.cwd(), deps = defaultDeps) {
3153
+ const [toplevelResult, commonDirResult] = await Promise.all([
3154
+ deps.execa(GIT_ROOT_COMMAND.EXECUTABLE, [...GIT_SHOW_TOPLEVEL_ARGS], { cwd, reject: false }),
3155
+ deps.execa(GIT_ROOT_COMMAND.EXECUTABLE, [...GIT_COMMON_DIR_ARGS], { cwd, reject: false })
3156
+ ]);
3157
+ if (toplevelResult.exitCode !== 0) return false;
3158
+ if (commonDirResult.exitCode !== 0) return true;
3253
3159
  const toplevel = extractStdout(toplevelResult.stdout);
3254
3160
  const commonDir = extractStdout(commonDirResult.stdout);
3255
- const worktree = toplevelResult.exitCode === 0 && commonDirResult.exitCode === 0 ? computeRelativeWorktreePath(commonDir, toplevel) : "";
3256
- return { branch, worktree };
3161
+ return computeRelativeWorktreePath(commonDir, toplevel) === "";
3257
3162
  }
3258
3163
  async function resolveSessionConfig(options = {}) {
3259
3164
  const { sessionsDir, cwd, deps } = options;
@@ -3326,11 +3231,6 @@ async function resolveArchivePaths(sessionId, config) {
3326
3231
  }
3327
3232
  async function archiveSingle(sessionId, config) {
3328
3233
  const { source, target } = await resolveArchivePaths(sessionId, config);
3329
- const content = await readFile3(source, "utf-8");
3330
- const metadata = parseSessionMetadata(content);
3331
- if (metadata.result.trim().length === 0) {
3332
- throw new SessionInvalidResultError(sessionId);
3333
- }
3334
3234
  await mkdir(dirname2(target), { recursive: true });
3335
3235
  await rename(source, target);
3336
3236
  return `${SESSION_ARCHIVE_OUTPUT.ARCHIVED}: ${sessionId}
@@ -3355,63 +3255,197 @@ function resolveDeletePath(sessionId, existingPaths) {
3355
3255
 
3356
3256
  // src/domains/session/show.ts
3357
3257
  import { join as join5 } from "path";
3358
- var SESSION_SHOW_LABEL = {
3359
- ID: "ID",
3360
- STATUS: "Status",
3361
- PRIORITY: "Priority",
3362
- BRANCH: "Branch",
3363
- WORKTREE: "Worktree",
3364
- GOAL: "Goal",
3365
- NEXT_STEP: "Next step",
3366
- RESULT: "Result",
3367
- CREATED: "Created",
3368
- AGENT_SESSION: "Agent session"
3369
- };
3370
- var SESSION_SHOW_SEPARATOR_CHAR = "\u2500";
3371
- var SESSION_SHOW_SEPARATOR_WIDTH = 40;
3372
- var { dir: sessionsBaseDir, statusDirs } = DEFAULT_CONFIG.sessions;
3373
- var DEFAULT_SESSION_CONFIG = {
3374
- todoDir: join5(sessionsBaseDir, statusDirs.todo),
3375
- doingDir: join5(sessionsBaseDir, statusDirs.doing),
3376
- archiveDir: join5(sessionsBaseDir, statusDirs.archive)
3377
- };
3378
- var SEARCH_ORDER = [...SESSION_STATUSES];
3379
- function resolveSessionPaths(id, config = DEFAULT_SESSION_CONFIG) {
3380
- const filename = `${id}.md`;
3381
- return [
3382
- `${config.todoDir}/${filename}`,
3383
- `${config.doingDir}/${filename}`,
3384
- `${config.archiveDir}/${filename}`
3385
- ];
3258
+
3259
+ // src/domains/session/list.ts
3260
+ import { parse as parseYaml2 } from "yaml";
3261
+
3262
+ // src/domains/session/timestamp.ts
3263
+ var SESSION_ID_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})$/;
3264
+ var SESSION_ID_SEPARATOR = "_";
3265
+ function generateSessionId(options = {}) {
3266
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
3267
+ const date = now();
3268
+ const year = date.getFullYear();
3269
+ const month = String(date.getMonth() + 1).padStart(2, "0");
3270
+ const day = String(date.getDate()).padStart(2, "0");
3271
+ const hours = String(date.getHours()).padStart(2, "0");
3272
+ const minutes = String(date.getMinutes()).padStart(2, "0");
3273
+ const seconds = String(date.getSeconds()).padStart(2, "0");
3274
+ return `${year}-${month}-${day}${SESSION_ID_SEPARATOR}${hours}-${minutes}-${seconds}`;
3386
3275
  }
3387
- function formatShowOutput(content, options) {
3388
- const metadata = parseSessionMetadata(content);
3389
- const headerLines = [
3390
- `${SESSION_SHOW_LABEL.ID}: ${metadata.id ?? ""}`,
3391
- `${SESSION_SHOW_LABEL.STATUS}: ${options.status}`,
3392
- `${SESSION_SHOW_LABEL.PRIORITY}: ${metadata.priority}`,
3393
- `${SESSION_SHOW_LABEL.BRANCH}: ${metadata.branch}`,
3394
- `${SESSION_SHOW_LABEL.WORKTREE}: ${metadata.worktree}`,
3395
- `${SESSION_SHOW_LABEL.GOAL}: ${metadata.goal}`,
3396
- `${SESSION_SHOW_LABEL.NEXT_STEP}: ${metadata.next_step}`,
3397
- `${SESSION_SHOW_LABEL.RESULT}: ${metadata.result}`,
3398
- `${SESSION_SHOW_LABEL.CREATED}: ${metadata.created_at ?? ""}`,
3399
- `${SESSION_SHOW_LABEL.AGENT_SESSION}: ${metadata.agent_session_id ?? ""}`
3400
- ];
3401
- const header = headerLines.join("\n");
3402
- const separator = "\n" + SESSION_SHOW_SEPARATOR_CHAR.repeat(SESSION_SHOW_SEPARATOR_WIDTH) + "\n\n";
3403
- return header + separator + content;
3276
+ function parseSessionId(id) {
3277
+ const match = SESSION_ID_PATTERN.exec(id);
3278
+ if (!match) {
3279
+ return null;
3280
+ }
3281
+ const [, yearStr, monthStr, dayStr, hoursStr, minutesStr, secondsStr] = match;
3282
+ const year = parseInt(yearStr, 10);
3283
+ const month = parseInt(monthStr, 10) - 1;
3284
+ const day = parseInt(dayStr, 10);
3285
+ const hours = parseInt(hoursStr, 10);
3286
+ const minutes = parseInt(minutesStr, 10);
3287
+ const seconds = parseInt(secondsStr, 10);
3288
+ if (month < 0 || month > 11) return null;
3289
+ if (day < 1 || day > 31) return null;
3290
+ if (hours < 0 || hours > 23) return null;
3291
+ if (minutes < 0 || minutes > 59) return null;
3292
+ if (seconds < 0 || seconds > 59) return null;
3293
+ return new Date(year, month, day, hours, minutes, seconds);
3404
3294
  }
3405
3295
 
3406
- // src/commands/session/delete.ts
3407
- var SESSION_DELETE_OUTPUT = {
3408
- DELETED: "Deleted session"
3296
+ // src/domains/session/types.ts
3297
+ var SESSION_PRIORITY = {
3298
+ HIGH: "high",
3299
+ MEDIUM: "medium",
3300
+ LOW: "low"
3409
3301
  };
3410
- async function findExistingPaths(paths) {
3411
- const existing = [];
3412
- for (const path6 of paths) {
3413
- try {
3414
- const stats = await stat2(path6);
3302
+ var SESSION_STATUSES = ["todo", "doing", "archive"];
3303
+ var DEFAULT_LIST_STATUSES = ["doing", "todo"];
3304
+ var PRIORITY_ORDER = {
3305
+ [SESSION_PRIORITY.HIGH]: 0,
3306
+ [SESSION_PRIORITY.MEDIUM]: 1,
3307
+ [SESSION_PRIORITY.LOW]: 2
3308
+ };
3309
+ var DEFAULT_PRIORITY = SESSION_PRIORITY.MEDIUM;
3310
+ var SESSION_FRONT_MATTER = {
3311
+ PRIORITY: "priority",
3312
+ GIT_REF: "git_ref",
3313
+ GOAL: "goal",
3314
+ NEXT_STEP: "next_step",
3315
+ CREATED_AT: "created_at",
3316
+ AGENT_SESSION_ID: "agent_session_id",
3317
+ SPECS: "specs",
3318
+ FILES: "files"
3319
+ };
3320
+
3321
+ // src/domains/session/list.ts
3322
+ var FRONT_MATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n?/;
3323
+ var SESSION_PRIORITY_VALUES = Object.values(SESSION_PRIORITY);
3324
+ var DEFAULT_SESSION_METADATA = {
3325
+ priority: DEFAULT_PRIORITY,
3326
+ git_ref: "",
3327
+ goal: "",
3328
+ next_step: "",
3329
+ specs: [],
3330
+ files: []
3331
+ };
3332
+ function defaultSessionMetadata() {
3333
+ return {
3334
+ ...DEFAULT_SESSION_METADATA,
3335
+ specs: [],
3336
+ files: []
3337
+ };
3338
+ }
3339
+ function isValidPriority(value) {
3340
+ return typeof value === "string" && SESSION_PRIORITY_VALUES.some((priority) => priority === value);
3341
+ }
3342
+ function parseSessionMetadata(content) {
3343
+ const match = FRONT_MATTER_PATTERN.exec(content);
3344
+ if (!match) {
3345
+ return defaultSessionMetadata();
3346
+ }
3347
+ try {
3348
+ const parsed = parseYaml2(match[1]);
3349
+ if (!parsed || typeof parsed !== "object") {
3350
+ return defaultSessionMetadata();
3351
+ }
3352
+ const rawPriority = parsed[SESSION_FRONT_MATTER.PRIORITY];
3353
+ const priority = isValidPriority(rawPriority) ? rawPriority : DEFAULT_PRIORITY;
3354
+ const metadata = {
3355
+ ...defaultSessionMetadata(),
3356
+ priority
3357
+ };
3358
+ const gitRef = parsed[SESSION_FRONT_MATTER.GIT_REF];
3359
+ metadata.git_ref = typeof gitRef === "string" ? gitRef : DEFAULT_SESSION_METADATA.git_ref;
3360
+ const goal = parsed[SESSION_FRONT_MATTER.GOAL];
3361
+ metadata.goal = typeof goal === "string" ? goal : DEFAULT_SESSION_METADATA.goal;
3362
+ const nextStep = parsed[SESSION_FRONT_MATTER.NEXT_STEP];
3363
+ metadata.next_step = typeof nextStep === "string" ? nextStep : DEFAULT_SESSION_METADATA.next_step;
3364
+ const createdAt = parsed[SESSION_FRONT_MATTER.CREATED_AT];
3365
+ if (typeof createdAt === "string") metadata.created_at = createdAt;
3366
+ const agentSessionId = parsed[SESSION_FRONT_MATTER.AGENT_SESSION_ID];
3367
+ if (typeof agentSessionId === "string") metadata.agent_session_id = agentSessionId;
3368
+ const specs = parsed[SESSION_FRONT_MATTER.SPECS];
3369
+ if (Array.isArray(specs)) {
3370
+ metadata.specs = specs.filter((s) => typeof s === "string");
3371
+ }
3372
+ const files = parsed[SESSION_FRONT_MATTER.FILES];
3373
+ if (Array.isArray(files)) {
3374
+ metadata.files = files.filter((f) => typeof f === "string");
3375
+ }
3376
+ return metadata;
3377
+ } catch {
3378
+ return defaultSessionMetadata();
3379
+ }
3380
+ }
3381
+ function sortSessions(sessions) {
3382
+ return [...sessions].sort((a, b) => {
3383
+ const priorityA = PRIORITY_ORDER[a.metadata.priority];
3384
+ const priorityB = PRIORITY_ORDER[b.metadata.priority];
3385
+ if (priorityA !== priorityB) {
3386
+ return priorityA - priorityB;
3387
+ }
3388
+ const dateA = parseSessionId(a.id);
3389
+ const dateB = parseSessionId(b.id);
3390
+ if (!dateA && !dateB) return 0;
3391
+ if (!dateA) return 1;
3392
+ if (!dateB) return -1;
3393
+ return dateB.getTime() - dateA.getTime();
3394
+ });
3395
+ }
3396
+
3397
+ // src/domains/session/show.ts
3398
+ var SESSION_SHOW_LABEL = {
3399
+ STATUS: "Status",
3400
+ PRIORITY: "Priority",
3401
+ GIT_REF: "Git ref",
3402
+ GOAL: "Goal",
3403
+ NEXT_STEP: "Next step",
3404
+ CREATED: "Created",
3405
+ AGENT_SESSION: "Agent session"
3406
+ };
3407
+ var SESSION_SHOW_SEPARATOR_CHAR = "\u2500";
3408
+ var SESSION_SHOW_SEPARATOR_WIDTH = 40;
3409
+ var { dir: sessionsBaseDir, statusDirs } = DEFAULT_CONFIG.sessions;
3410
+ var DEFAULT_SESSION_CONFIG = {
3411
+ todoDir: join5(sessionsBaseDir, statusDirs.todo),
3412
+ doingDir: join5(sessionsBaseDir, statusDirs.doing),
3413
+ archiveDir: join5(sessionsBaseDir, statusDirs.archive)
3414
+ };
3415
+ var SEARCH_ORDER = [...SESSION_STATUSES];
3416
+ function resolveSessionPaths(id, config = DEFAULT_SESSION_CONFIG) {
3417
+ const filename = `${id}.md`;
3418
+ return [
3419
+ `${config.todoDir}/${filename}`,
3420
+ `${config.doingDir}/${filename}`,
3421
+ `${config.archiveDir}/${filename}`
3422
+ ];
3423
+ }
3424
+ function formatShowOutput(content, options) {
3425
+ const metadata = parseSessionMetadata(content);
3426
+ const headerLines = [
3427
+ `${SESSION_SHOW_LABEL.STATUS}: ${options.status}`,
3428
+ `${SESSION_SHOW_LABEL.PRIORITY}: ${metadata.priority}`,
3429
+ `${SESSION_SHOW_LABEL.GIT_REF}: ${metadata.git_ref}`,
3430
+ `${SESSION_SHOW_LABEL.GOAL}: ${metadata.goal}`,
3431
+ `${SESSION_SHOW_LABEL.NEXT_STEP}: ${metadata.next_step}`,
3432
+ `${SESSION_SHOW_LABEL.CREATED}: ${metadata.created_at ?? ""}`,
3433
+ `${SESSION_SHOW_LABEL.AGENT_SESSION}: ${metadata.agent_session_id ?? ""}`
3434
+ ];
3435
+ const header = headerLines.join("\n");
3436
+ const separator = "\n" + SESSION_SHOW_SEPARATOR_CHAR.repeat(SESSION_SHOW_SEPARATOR_WIDTH) + "\n\n";
3437
+ return header + separator + content;
3438
+ }
3439
+
3440
+ // src/commands/session/delete.ts
3441
+ var SESSION_DELETE_OUTPUT = {
3442
+ DELETED: "Deleted session"
3443
+ };
3444
+ async function findExistingPaths(paths) {
3445
+ const existing = [];
3446
+ for (const path6 of paths) {
3447
+ try {
3448
+ const stats = await stat2(path6);
3415
3449
  if (stats.isFile()) {
3416
3450
  existing.push(path6);
3417
3451
  }
@@ -3435,49 +3469,186 @@ async function deleteCommand(options) {
3435
3469
  // src/commands/session/handoff.ts
3436
3470
  import { mkdir as mkdir2, writeFile } from "fs/promises";
3437
3471
  import { join as join6, resolve as resolve4 } from "path";
3472
+ import { stringify as stringifyYaml3 } from "yaml";
3438
3473
 
3439
3474
  // src/domains/session/create.ts
3440
- import { parse as parseYaml3, stringify as stringifyYaml2 } from "yaml";
3475
+ import { stringify as stringifyYaml2 } from "yaml";
3441
3476
  var SESSION_FRONT_MATTER_DELIMITER = "---";
3442
3477
  var SESSION_FRONT_MATTER_OPEN = `${SESSION_FRONT_MATTER_DELIMITER}
3443
3478
  `;
3444
3479
  var SESSION_FRONT_MATTER_CLOSE = `
3445
3480
  ${SESSION_FRONT_MATTER_DELIMITER}
3446
3481
  `;
3447
- var FRONT_MATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n?/;
3448
- function preFillSessionContent(content, params) {
3449
- const match = FRONT_MATTER_BLOCK.exec(content);
3450
- if (!match) return content;
3451
- const parsed = parseYaml3(match[1]);
3452
- const frontMatter = parsed && typeof parsed === "object" ? parsed : {};
3453
- frontMatter[SESSION_FRONT_MATTER.CREATED_AT] = params.createdAt.toISOString();
3454
- frontMatter[SESSION_FRONT_MATTER.BRANCH] = params.branch;
3455
- frontMatter[SESSION_FRONT_MATTER.WORKTREE] = params.worktree;
3456
- if (params.agentSessionId !== void 0) {
3457
- frontMatter[SESSION_FRONT_MATTER.AGENT_SESSION_ID] = params.agentSessionId;
3458
- } else {
3459
- delete frontMatter[SESSION_FRONT_MATTER.AGENT_SESSION_ID];
3482
+
3483
+ // src/domains/session/handoff-base.ts
3484
+ function resolveHandoffGitRef(facts) {
3485
+ if (facts.isRootWorktree) {
3486
+ if (facts.branch !== null) return facts.branch;
3487
+ if (facts.headSha !== null) return facts.headSha;
3488
+ throw new SessionHandoffBaseError();
3489
+ }
3490
+ if (facts.branch !== null) {
3491
+ throw new SessionHandoffBaseError();
3492
+ }
3493
+ if (!facts.isClean) {
3494
+ throw new SessionHandoffBaseError();
3495
+ }
3496
+ if (facts.headSha === null || facts.defaultTipSha === null || facts.headSha !== facts.defaultTipSha) {
3497
+ throw new SessionHandoffBaseError();
3498
+ }
3499
+ return facts.headSha;
3500
+ }
3501
+
3502
+ // src/domains/session/parse-handoff-input.ts
3503
+ var LEGACY_FRONTMATTER_PREFIX = /^---\r?\n/;
3504
+ var JSON_OBJECT_OPEN_CHAR = "{";
3505
+ var CARRIAGE_RETURN_CHAR_CODE = "\r".charCodeAt(0);
3506
+ var NEWLINE_CHAR_CODE = "\n".charCodeAt(0);
3507
+ var BACKSLASH_CHAR_CODE = "\\".charCodeAt(0);
3508
+ var DOUBLE_QUOTE_CHAR_CODE = '"'.charCodeAt(0);
3509
+ var OPEN_BRACE_CHAR_CODE = "{".charCodeAt(0);
3510
+ var CLOSE_BRACE_CHAR_CODE = "}".charCodeAt(0);
3511
+ var UNBALANCED_HEADER_END = -1;
3512
+ var LF_SEPARATOR_LENGTH = 1;
3513
+ var CRLF_SEPARATOR_LENGTH = 2;
3514
+ var SESSION_PRIORITY_VALUES2 = new Set(Object.values(SESSION_PRIORITY));
3515
+ function parseHandoffInput(stdin) {
3516
+ if (LEGACY_FRONTMATTER_PREFIX.test(stdin)) {
3517
+ throw new SessionLegacyFrontmatterInputError();
3518
+ }
3519
+ if (!stdin.startsWith(JSON_OBJECT_OPEN_CHAR)) {
3520
+ throw new SessionInvalidJsonHeaderError(
3521
+ "stdin must begin with a JSON object opening with '{'"
3522
+ );
3460
3523
  }
3461
- return `${SESSION_FRONT_MATTER_OPEN}${stringifyYaml2(frontMatter).trimEnd()}
3462
- ${SESSION_FRONT_MATTER_DELIMITER}
3463
- ${content.slice(match[0].length)}`;
3524
+ const headerEnd = findJsonObjectEnd(stdin);
3525
+ if (headerEnd === UNBALANCED_HEADER_END) {
3526
+ throw new SessionInvalidJsonHeaderError(
3527
+ "unbalanced JSON object \u2014 opening brace has no matching close"
3528
+ );
3529
+ }
3530
+ const headerText = stdin.slice(0, headerEnd + 1);
3531
+ let parsed;
3532
+ try {
3533
+ parsed = JSON.parse(headerText);
3534
+ } catch (err) {
3535
+ const detail = err instanceof Error ? err.message : "JSON.parse failed";
3536
+ throw new SessionInvalidJsonHeaderError(detail);
3537
+ }
3538
+ const header = validateHandoffHeader(parsed);
3539
+ const bodyStart = consumeOptionalBodySeparator(stdin, headerEnd + 1);
3540
+ return { header, body: stdin.slice(bodyStart) };
3541
+ }
3542
+ function consumeOptionalBodySeparator(stdin, bodyStart) {
3543
+ if (stdin.charCodeAt(bodyStart) === CARRIAGE_RETURN_CHAR_CODE && stdin.charCodeAt(bodyStart + LF_SEPARATOR_LENGTH) === NEWLINE_CHAR_CODE) {
3544
+ return bodyStart + CRLF_SEPARATOR_LENGTH;
3545
+ }
3546
+ if (stdin.charCodeAt(bodyStart) === NEWLINE_CHAR_CODE) {
3547
+ return bodyStart + LF_SEPARATOR_LENGTH;
3548
+ }
3549
+ return bodyStart;
3550
+ }
3551
+ function findJsonObjectEnd(stdin) {
3552
+ let depth = 0;
3553
+ let inString = false;
3554
+ for (let i = 0; i < stdin.length; i++) {
3555
+ const code = stdin.charCodeAt(i);
3556
+ if (inString) {
3557
+ if (code === BACKSLASH_CHAR_CODE) {
3558
+ i += 1;
3559
+ continue;
3560
+ }
3561
+ if (code === DOUBLE_QUOTE_CHAR_CODE) {
3562
+ inString = false;
3563
+ }
3564
+ continue;
3565
+ }
3566
+ if (code === DOUBLE_QUOTE_CHAR_CODE) {
3567
+ inString = true;
3568
+ continue;
3569
+ }
3570
+ if (code === OPEN_BRACE_CHAR_CODE) {
3571
+ depth += 1;
3572
+ continue;
3573
+ }
3574
+ if (code === CLOSE_BRACE_CHAR_CODE) {
3575
+ depth -= 1;
3576
+ if (depth === 0) return i;
3577
+ }
3578
+ }
3579
+ return UNBALANCED_HEADER_END;
3464
3580
  }
3465
-
3466
- // src/commands/session/handoff.ts
3467
- function buildSessionContent(content) {
3468
- if (!content || content.trim().length === 0) {
3469
- throw new SessionInvalidContentError("Session content cannot be empty");
3581
+ function validateHandoffHeader(parsed) {
3582
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3583
+ throw new SessionInvalidJsonHeaderError("header must be a JSON object");
3470
3584
  }
3471
- return content;
3585
+ const obj = parsed;
3586
+ const priority = ensurePriorityOrDefault(obj[SESSION_FRONT_MATTER.PRIORITY]);
3587
+ const goal = ensureStringOrEmpty(obj[SESSION_FRONT_MATTER.GOAL], SESSION_FRONT_MATTER.GOAL);
3588
+ const nextStep = ensureStringOrEmpty(obj[SESSION_FRONT_MATTER.NEXT_STEP], SESSION_FRONT_MATTER.NEXT_STEP);
3589
+ const specs = ensureStringArrayOrDefault(obj[SESSION_FRONT_MATTER.SPECS], SESSION_FRONT_MATTER.SPECS);
3590
+ const files = ensureStringArrayOrDefault(obj[SESSION_FRONT_MATTER.FILES], SESSION_FRONT_MATTER.FILES);
3591
+ return {
3592
+ priority,
3593
+ goal,
3594
+ next_step: nextStep,
3595
+ specs,
3596
+ files
3597
+ };
3472
3598
  }
3473
- function validateRequiredMetadata(content) {
3474
- const metadata = parseSessionMetadata(content);
3475
- if (metadata.goal.trim().length === 0) {
3476
- throw new SessionInvalidGoalError();
3599
+ function ensurePriorityOrDefault(value) {
3600
+ if (value === void 0) return DEFAULT_PRIORITY;
3601
+ if (typeof value !== "string" || !SESSION_PRIORITY_VALUES2.has(value)) {
3602
+ throw new SessionInvalidJsonHeaderError(
3603
+ `${SESSION_FRONT_MATTER.PRIORITY} must be one of ${[...SESSION_PRIORITY_VALUES2].join(", ")}`
3604
+ );
3477
3605
  }
3478
- if (metadata.next_step.trim().length === 0) {
3479
- throw new SessionInvalidNextStepError();
3606
+ return value;
3607
+ }
3608
+ function ensureStringOrEmpty(value, fieldName) {
3609
+ if (value === void 0) return "";
3610
+ if (typeof value !== "string") {
3611
+ throw new SessionInvalidJsonHeaderError(`${fieldName} must be a string`);
3612
+ }
3613
+ return value;
3614
+ }
3615
+ function ensureStringArrayOrDefault(value, fieldName) {
3616
+ if (value === void 0) return [];
3617
+ if (!Array.isArray(value)) {
3618
+ throw new SessionInvalidJsonHeaderError(`${fieldName} must be an array of strings`);
3619
+ }
3620
+ for (const item of value) {
3621
+ if (typeof item !== "string") {
3622
+ throw new SessionInvalidJsonHeaderError(`${fieldName} must be an array of strings`);
3623
+ }
3480
3624
  }
3625
+ return value;
3626
+ }
3627
+
3628
+ // src/commands/session/handoff.ts
3629
+ async function resolveSessionGitRef(cwd, deps) {
3630
+ const [isRoot, branch, headSha] = await Promise.all([
3631
+ isRootWorktree(cwd, deps),
3632
+ getCurrentBranch(cwd, deps),
3633
+ getHeadSha(cwd, deps)
3634
+ ]);
3635
+ let isClean = false;
3636
+ let defaultTipSha = null;
3637
+ if (!isRoot && branch === null) {
3638
+ const [clean, defaultBranch] = await Promise.all([
3639
+ isWorkingTreeClean(cwd, deps),
3640
+ resolveDefaultBranch(cwd, deps)
3641
+ ]);
3642
+ isClean = clean;
3643
+ defaultTipSha = defaultBranch === null ? null : await resolveRefSha(`${ORIGIN_REF_PREFIX}${defaultBranch}`, cwd, deps);
3644
+ }
3645
+ return resolveHandoffGitRef({
3646
+ isRootWorktree: isRoot,
3647
+ branch,
3648
+ headSha,
3649
+ isClean,
3650
+ defaultTipSha
3651
+ });
3481
3652
  }
3482
3653
  async function handoffCommand(options) {
3483
3654
  const { config, warning } = await resolveSessionConfig({
@@ -3485,32 +3656,47 @@ async function handoffCommand(options) {
3485
3656
  cwd: options.cwd,
3486
3657
  deps: options.deps
3487
3658
  });
3488
- const baseContent = buildSessionContent(options.content);
3489
- const workContext = await detectSessionWorkContext(options.cwd, options.deps);
3659
+ const content = options.content;
3660
+ if (content === void 0 || content.trim().length === 0) {
3661
+ throw new SessionInvalidContentError("Session content cannot be empty");
3662
+ }
3663
+ const { header, body } = parseHandoffInput(content);
3664
+ if (header.goal.length === 0) {
3665
+ throw new SessionInvalidGoalError();
3666
+ }
3667
+ if (header.next_step.length === 0) {
3668
+ throw new SessionInvalidNextStepError();
3669
+ }
3670
+ const gitRef = await resolveSessionGitRef(options.cwd, options.deps);
3490
3671
  const sessionId = generateSessionId();
3491
3672
  const agentSessionId = process.env.CLAUDE_SESSION_ID ?? process.env.CODEX_THREAD_ID;
3492
- const fullContent = preFillSessionContent(baseContent, {
3493
- createdAt: /* @__PURE__ */ new Date(),
3494
- agentSessionId,
3495
- branch: workContext.branch,
3496
- worktree: workContext.worktree
3497
- });
3498
- validateRequiredMetadata(fullContent);
3673
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
3674
+ const frontMatterObject = {
3675
+ [SESSION_FRONT_MATTER.PRIORITY]: header.priority,
3676
+ [SESSION_FRONT_MATTER.CREATED_AT]: createdAt,
3677
+ [SESSION_FRONT_MATTER.GIT_REF]: gitRef,
3678
+ [SESSION_FRONT_MATTER.GOAL]: header.goal,
3679
+ [SESSION_FRONT_MATTER.NEXT_STEP]: header.next_step,
3680
+ [SESSION_FRONT_MATTER.SPECS]: [...header.specs],
3681
+ [SESSION_FRONT_MATTER.FILES]: [...header.files]
3682
+ };
3683
+ if (agentSessionId !== void 0) {
3684
+ frontMatterObject[SESSION_FRONT_MATTER.AGENT_SESSION_ID] = agentSessionId;
3685
+ }
3686
+ const yaml = stringifyYaml3(frontMatterObject, { defaultStringType: "QUOTE_DOUBLE" }).trimEnd();
3687
+ const fullContent = `${SESSION_FRONT_MATTER_OPEN}${yaml}${SESSION_FRONT_MATTER_CLOSE}${body}`;
3499
3688
  const filename = `${sessionId}.md`;
3500
3689
  const sessionPath = join6(config.todoDir, filename);
3501
3690
  const absolutePath = resolve4(sessionPath);
3502
3691
  await mkdir2(config.todoDir, { recursive: true });
3503
3692
  await writeFile(sessionPath, fullContent, "utf-8");
3504
- if (warning) {
3505
- process.stderr.write(`${warning}
3506
- `);
3507
- }
3508
- return `Created handoff session <HANDOFF_ID>${sessionId}</HANDOFF_ID>
3693
+ const output = `Created handoff session <HANDOFF_ID>${sessionId}</HANDOFF_ID>
3509
3694
  <SESSION_FILE>${absolutePath}</SESSION_FILE>`;
3695
+ return warning ? { output, warning } : { output };
3510
3696
  }
3511
3697
 
3512
3698
  // src/commands/session/list.ts
3513
- import { readdir, readFile as readFile4 } from "fs/promises";
3699
+ import { readdir, readFile as readFile3 } from "fs/promises";
3514
3700
  import { join as join7 } from "path";
3515
3701
  var SESSION_LIST_FORMAT = {
3516
3702
  TEXT: "text",
@@ -3526,7 +3712,7 @@ async function loadSessionsFromDir(dir, status) {
3526
3712
  if (!file.endsWith(".md")) continue;
3527
3713
  const id = file.replace(".md", "");
3528
3714
  const filePath = join7(dir, file);
3529
- const content = await readFile4(filePath, "utf-8");
3715
+ const content = await readFile3(filePath, "utf-8");
3530
3716
  const metadata = parseSessionMetadata(content);
3531
3717
  sessions.push({
3532
3718
  id,
@@ -3588,7 +3774,7 @@ async function listCommand(options) {
3588
3774
  }
3589
3775
 
3590
3776
  // src/commands/session/pickup.ts
3591
- import { mkdir as mkdir3, readdir as readdir2, readFile as readFile5, rename as rename2 } from "fs/promises";
3777
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rename as rename2 } from "fs/promises";
3592
3778
  import { join as join8 } from "path";
3593
3779
 
3594
3780
  // src/domains/session/pickup.ts
@@ -3635,7 +3821,7 @@ async function loadTodoSessions(config) {
3635
3821
  if (!file.endsWith(".md")) continue;
3636
3822
  const id = file.replace(".md", "");
3637
3823
  const filePath = join8(config.todoDir, file);
3638
- const content = await readFile5(filePath, "utf-8");
3824
+ const content = await readFile4(filePath, "utf-8");
3639
3825
  const metadata = parseSessionMetadata(content);
3640
3826
  sessions.push({
3641
3827
  id,
@@ -3652,21 +3838,7 @@ async function loadTodoSessions(config) {
3652
3838
  throw error;
3653
3839
  }
3654
3840
  }
3655
- async function pickupCommand(options) {
3656
- const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
3657
- let sessionId;
3658
- if (options.auto) {
3659
- const sessions = await loadTodoSessions(config);
3660
- const selected = selectBestSession(sessions);
3661
- if (!selected) {
3662
- throw new NoSessionsAvailableError();
3663
- }
3664
- sessionId = selected.id;
3665
- } else if (options.sessionId) {
3666
- sessionId = options.sessionId;
3667
- } else {
3668
- throw new Error("Either session ID or --auto flag is required");
3669
- }
3841
+ async function pickupSingle(sessionId, config) {
3670
3842
  const paths = buildClaimPaths(sessionId, config);
3671
3843
  await mkdir3(config.doingDir, { recursive: true });
3672
3844
  try {
@@ -3674,15 +3846,36 @@ async function pickupCommand(options) {
3674
3846
  } catch (error) {
3675
3847
  throw classifyClaimError(error, sessionId);
3676
3848
  }
3677
- const content = await readFile5(paths.target, "utf-8");
3849
+ const content = await readFile4(paths.target, "utf-8");
3678
3850
  const output = formatShowOutput(content, { status: PICKUP_TARGET_STATUS });
3679
3851
  return `Claimed session <PICKUP_ID>${sessionId}</PICKUP_ID>
3680
3852
 
3681
3853
  ${output}`;
3682
3854
  }
3855
+ async function pickupCommand(options) {
3856
+ const { config } = await resolveSessionConfig({ sessionsDir: options.sessionsDir });
3857
+ if (options.auto) {
3858
+ if (options.sessionIds.length > 0) {
3859
+ throw new Error("Session IDs cannot be combined with --auto");
3860
+ }
3861
+ const sessions = await loadTodoSessions(config);
3862
+ const selected = selectBestSession(sessions);
3863
+ if (!selected) {
3864
+ throw new NoSessionsAvailableError();
3865
+ }
3866
+ return pickupSingle(selected.id, config);
3867
+ }
3868
+ if (options.sessionIds.length === 0) {
3869
+ throw new Error("Either session ID or --auto flag is required");
3870
+ }
3871
+ if (options.sessionIds.length === 1) {
3872
+ return pickupSingle(options.sessionIds[0], config);
3873
+ }
3874
+ return processBatch(options.sessionIds, (id) => pickupSingle(id, config));
3875
+ }
3683
3876
 
3684
3877
  // src/commands/session/prune.ts
3685
- import { readdir as readdir3, readFile as readFile6, unlink as unlink2 } from "fs/promises";
3878
+ import { readdir as readdir3, readFile as readFile5, unlink as unlink2 } from "fs/promises";
3686
3879
  import { join as join9 } from "path";
3687
3880
 
3688
3881
  // src/domains/session/prune.ts
@@ -3733,7 +3926,7 @@ async function loadArchiveSessions(config) {
3733
3926
  if (!file.endsWith(".md")) continue;
3734
3927
  const id = file.replace(".md", "");
3735
3928
  const filePath = join9(config.archiveDir, file);
3736
- const content = await readFile6(filePath, "utf-8");
3929
+ const content = await readFile5(filePath, "utf-8");
3737
3930
  const metadata = parseSessionMetadata(content);
3738
3931
  sessions.push({
3739
3932
  id,
@@ -3850,7 +4043,7 @@ async function releaseCommand(options) {
3850
4043
  }
3851
4044
 
3852
4045
  // src/commands/session/show.ts
3853
- import { readFile as readFile7, stat as stat3 } from "fs/promises";
4046
+ import { readFile as readFile6, stat as stat3 } from "fs/promises";
3854
4047
  async function findExistingPath(paths, _config) {
3855
4048
  for (let i = 0; i < paths.length; i++) {
3856
4049
  const filePath = paths[i];
@@ -3870,7 +4063,7 @@ async function showSingle(sessionId, config) {
3870
4063
  if (!found) {
3871
4064
  throw new SessionNotFoundError(sessionId);
3872
4065
  }
3873
- const content = await readFile7(found.path, "utf-8");
4066
+ const content = await readFile6(found.path, "utf-8");
3874
4067
  return formatShowOutput(content, { status: found.status });
3875
4068
  }
3876
4069
  async function showCommand2(options) {
@@ -3885,9 +4078,9 @@ Session File Format:
3885
4078
 
3886
4079
  ---
3887
4080
  priority: high
4081
+ git_ref: main
3888
4082
  goal: Fix the failing release check
3889
4083
  next_step: Run the focused session tests
3890
- result: Local validation passed
3891
4084
  specs: []
3892
4085
  files: []
3893
4086
  ---
@@ -3896,43 +4089,47 @@ Session File Format:
3896
4089
  Session content...
3897
4090
 
3898
4091
  Workflow:
3899
- 1. handoff - Create session (todo)
4092
+ 1. handoff - Create session (todo) \u2014 JSON header + body on stdin
3900
4093
  2. pickup - Claim session (todo -> doing)
3901
4094
  3. release - Return session (doing -> todo)
3902
- 4. archive - Move session with a result to archive
4095
+ 4. archive - Move session to archive
3903
4096
  5. delete - Remove session permanently
3904
4097
  `;
3905
4098
  var HANDOFF_FRONTMATTER_HELP = `
3906
4099
  Usage:
3907
- Pipe content with frontmatter via stdin
4100
+ Pipe a JSON header followed by the body bytes to stdin.
3908
4101
 
3909
- Frontmatter Format:
3910
- ---
3911
- priority: high # high | medium | low (default: medium)
3912
- goal: Fix login # required
3913
- next_step: Run validation # required
3914
- specs: [] # optional pickup context
3915
- files: [] # optional pickup context
3916
- ---
3917
- # Your session content here...
4102
+ The header is a single JSON object holding caller-supplied structured fields.
4103
+ A single LF or CRLF after the header is consumed as a separator. The body
4104
+ is the remaining bytes verbatim \u2014 no YAML, no escape rules, no ambiguity
4105
+ from leading characters like '#' or '---'.
4106
+
4107
+ JSON Header Fields:
4108
+ priority "high" | "medium" | "low" (default: medium)
4109
+ goal required, non-empty string
4110
+ next_step required, non-empty string
4111
+ specs optional string[], for pickup auto-injection
4112
+ files optional string[], for pickup auto-injection
3918
4113
 
3919
4114
  Prefilled by the CLI:
3920
- created_at, branch, worktree, agent_session_id when an agent session ID is available
4115
+ created_at, git_ref, and agent_session_id when an agent session ID is
4116
+ available.
3921
4117
 
3922
- Before archive:
3923
- Add a non-empty result field to the frontmatter.
4118
+ git_ref records the branch name (root worktree on a branch), the HEAD SHA
4119
+ (detached), or the origin/<default> tip SHA (clean detached linked worktree).
4120
+ Handoff is refused from any other linked-worktree state.
3924
4121
 
3925
4122
  Output Tags (for automation):
3926
- <HANDOFF_ID>session-id</HANDOFF_ID> - Session identifier
3927
- <SESSION_FILE>/path/to/file</SESSION_FILE> - Absolute path to created file
3928
-
3929
- Examples:
3930
- echo '---
3931
- priority: high
3932
- goal: Fix login
3933
- next_step: Run validation
3934
- ---
3935
- # Fix login' | spx session handoff
4123
+ <HANDOFF_ID>session-id</HANDOFF_ID> Session identifier
4124
+ <SESSION_FILE>/path/to/file</SESSION_FILE> Absolute path to created file
4125
+
4126
+ Canonical Invocation:
4127
+ printf '%s\\n' \\
4128
+ '{"priority":"high","goal":"Fix login","next_step":"Run validation","specs":[],"files":[]}' \\
4129
+ '# Fix login' \\
4130
+ '' \\
4131
+ 'Body text \u2014 # symbols, --- delimiters, and code fences are literal.' \\
4132
+ | spx session handoff
3936
4133
  `;
3937
4134
  var PICKUP_SELECTION_HELP = `
3938
4135
  Selection Logic (--auto):
@@ -3943,10 +4140,10 @@ Selection Logic (--auto):
3943
4140
  4. Within same priority: oldest session first
3944
4141
 
3945
4142
  Output:
3946
- <PICKUP_ID>session-id</PICKUP_ID> tag for automation parsing
4143
+ <PICKUP_ID>session-id</PICKUP_ID> tag for each claimed session
3947
4144
  `;
3948
4145
 
3949
- // src/domains/session/index.ts
4146
+ // src/interfaces/cli/session.ts
3950
4147
  async function readStdin() {
3951
4148
  if (process.stdin.isTTY) {
3952
4149
  return void 0;
@@ -3958,7 +4155,7 @@ async function readStdin() {
3958
4155
  data += chunk;
3959
4156
  });
3960
4157
  process.stdin.on("end", () => {
3961
- resolve6(data.trim() || void 0);
4158
+ resolve6(data.length === 0 ? void 0 : data);
3962
4159
  });
3963
4160
  process.stdin.on("error", () => {
3964
4161
  resolve6(void 0);
@@ -4005,14 +4202,14 @@ function registerSessionCommands(sessionCmd) {
4005
4202
  handleError(error);
4006
4203
  }
4007
4204
  });
4008
- sessionCmd.command("pickup [id]").description("Claim a session (move from todo to doing)").option("--auto", "Auto-select highest priority session").option("--sessions-dir <path>", "Custom sessions directory").addHelpText("after", PICKUP_SELECTION_HELP).action(async (id, options) => {
4205
+ sessionCmd.command("pickup [ids...]").description("Claim one or more sessions (move from todo to doing)").option("--auto", "Auto-select highest priority session").option("--sessions-dir <path>", "Custom sessions directory").addHelpText("after", PICKUP_SELECTION_HELP).action(async (ids, options) => {
4009
4206
  try {
4010
- if (!id && !options.auto) {
4207
+ if (ids.length === 0 && !options.auto) {
4011
4208
  console.error("Error: Either session ID or --auto flag is required");
4012
4209
  process.exit(1);
4013
4210
  }
4014
4211
  const output = await pickupCommand({
4015
- sessionId: id,
4212
+ sessionIds: ids,
4016
4213
  auto: options.auto,
4017
4214
  sessionsDir: options.sessionsDir
4018
4215
  });
@@ -4032,14 +4229,18 @@ function registerSessionCommands(sessionCmd) {
4032
4229
  handleError(error);
4033
4230
  }
4034
4231
  });
4035
- sessionCmd.command("handoff").description("Create a handoff session (reads content with frontmatter from stdin)").option("--sessions-dir <path>", "Custom sessions directory").addHelpText("after", HANDOFF_FRONTMATTER_HELP).action(async (options) => {
4232
+ sessionCmd.command("handoff").description("Create a handoff session (reads JSON header + body from stdin)").option("--sessions-dir <path>", "Custom sessions directory").addHelpText("after", HANDOFF_FRONTMATTER_HELP).action(async (options) => {
4036
4233
  try {
4037
4234
  const content = await readStdin();
4038
- const output = await handoffCommand({
4235
+ const result = await handoffCommand({
4039
4236
  content,
4040
4237
  sessionsDir: options.sessionsDir
4041
4238
  });
4042
- console.log(output);
4239
+ if (result.warning !== void 0) {
4240
+ process.stderr.write(`${result.warning}
4241
+ `);
4242
+ }
4243
+ console.log(result.output);
4043
4244
  } catch (error) {
4044
4245
  handleError(error);
4045
4246
  }
@@ -4098,7 +4299,7 @@ var sessionDomain = {
4098
4299
  };
4099
4300
 
4100
4301
  // src/lib/spec-tree/index.ts
4101
- import { readdir as readdir5, readFile as readFile8 } from "fs/promises";
4302
+ import { readdir as readdir5, readFile as readFile7 } from "fs/promises";
4102
4303
  import { join as join10 } from "path";
4103
4304
  var SPEC_TREE_FIELD_KEY = {
4104
4305
  VERSION: "version",
@@ -4191,7 +4392,7 @@ function createFilesystemSpecTreeSource(options) {
4191
4392
  if (ref.path === void 0) {
4192
4393
  throw new Error("Filesystem source refs require a path");
4193
4394
  }
4194
- return readFile8(join10(options.productDir, ref.path), SPEC_TREE_TEXT_ENCODING);
4395
+ return readFile7(join10(options.productDir, ref.path), SPEC_TREE_TEXT_ENCODING);
4195
4396
  }
4196
4397
  };
4197
4398
  }
@@ -4690,7 +4891,7 @@ function formatNodeLabel(node) {
4690
4891
  ].join(STATUS_SEPARATOR);
4691
4892
  }
4692
4893
 
4693
- // src/domains/spec/index.ts
4894
+ // src/interfaces/cli/spec.ts
4694
4895
  var SPEC_DOMAIN_CLI = {
4695
4896
  COMMAND: "spec",
4696
4897
  STATUS_COMMAND: "status",
@@ -4760,6 +4961,9 @@ var specDomain = {
4760
4961
  }
4761
4962
  };
4762
4963
 
4964
+ // src/commands/validation/markdown.ts
4965
+ import { relative as relative4 } from "path";
4966
+
4763
4967
  // src/validation/config/path-filter.ts
4764
4968
  import { isAbsolute as isAbsolute2, relative as relative3 } from "path";
4765
4969
  var PATH_PREFIX_SEPARATOR = "/";
@@ -4855,28 +5059,313 @@ function applyValidationPathFilterToScope(scopeConfig, filter) {
4855
5059
  };
4856
5060
  }
4857
5061
 
4858
- // node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
4859
- function createScanner(text, ignoreTrivia = false) {
4860
- const len = text.length;
4861
- let pos = 0, value = "", tokenOffset = 0, token = 16, lineNumber = 0, lineStartOffset = 0, tokenLineStartOffset = 0, prevTokenLineStartOffset = 0, scanError = 0;
4862
- function scanHexDigits(count, exact) {
4863
- let digits = 0;
4864
- let value2 = 0;
4865
- while (digits < count || !exact) {
4866
- let ch = text.charCodeAt(pos);
4867
- if (ch >= 48 && ch <= 57) {
4868
- value2 = value2 * 16 + ch - 48;
4869
- } else if (ch >= 65 && ch <= 70) {
4870
- value2 = value2 * 16 + ch - 65 + 10;
4871
- } else if (ch >= 97 && ch <= 102) {
4872
- value2 = value2 * 16 + ch - 97 + 10;
4873
- } else {
4874
- break;
4875
- }
4876
- pos++;
4877
- digits++;
4878
- }
4879
- if (digits < count) {
5062
+ // src/validation/steps/markdown.ts
5063
+ import { existsSync as existsSync2, statSync } from "fs";
5064
+ import { basename, dirname as dirname3, join as join11, relative as pathRelative } from "path";
5065
+ import { main as markdownlintMain } from "markdownlint-cli2";
5066
+ import relativeLinksRule from "markdownlint-rule-relative-links";
5067
+ var MARKDOWN_DEFAULT_DIRECTORY_NAMES = ["spx", "docs"];
5068
+ var MARKDOWN_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown"]);
5069
+ var MARKDOWN_DIRECTORY_GLOB = "**/*.md";
5070
+ var ENABLED_RULES = {
5071
+ MD001: true,
5072
+ MD003: true,
5073
+ MD009: true,
5074
+ MD010: true,
5075
+ MD025: true,
5076
+ MD047: true
5077
+ };
5078
+ var MD024_DISABLED_DIRECTORIES = ["docs"];
5079
+ var MARKDOWN_CUSTOM_RULE_NAMES = relativeLinksRule.names;
5080
+ var MARKDOWN_VALIDATION_TARGET_KIND = {
5081
+ DIRECTORY: "directory",
5082
+ FILE: "file"
5083
+ };
5084
+ var MARKDOWN_VALIDATION_TARGET_DIAGNOSTICS = {
5085
+ MISSING_OR_UNRELATED_SCOPE: "not an existing directory or markdown file"
5086
+ };
5087
+ var ERROR_LINE_PATTERN = /^(.+?):(\d+)(?::\d+)?\s+(.+)$/;
5088
+ var defaultMarkdownValidationTargetDeps = {
5089
+ statSync
5090
+ };
5091
+ function buildMarkdownlintConfig(directoryName) {
5092
+ const md024Disabled = MD024_DISABLED_DIRECTORIES.includes(
5093
+ directoryName
5094
+ );
5095
+ return {
5096
+ default: false,
5097
+ ...ENABLED_RULES,
5098
+ MD024: md024Disabled ? false : { siblings_only: true },
5099
+ customRules: [relativeLinksRule]
5100
+ };
5101
+ }
5102
+ function getDefaultDirectories(projectRoot) {
5103
+ return MARKDOWN_DEFAULT_DIRECTORY_NAMES.map((name) => join11(projectRoot, name)).filter((dir) => existsSync2(dir));
5104
+ }
5105
+ function resolveMarkdownValidationTarget(path6, deps = defaultMarkdownValidationTargetDeps) {
5106
+ if (isExistingDirectory(path6, deps)) {
5107
+ return { target: { kind: MARKDOWN_VALIDATION_TARGET_KIND.DIRECTORY, path: path6 } };
5108
+ }
5109
+ if (hasMarkdownExtension(path6) && isExistingFile(path6, deps)) {
5110
+ return { target: { kind: MARKDOWN_VALIDATION_TARGET_KIND.FILE, path: path6 } };
5111
+ }
5112
+ return {
5113
+ skipped: {
5114
+ path: path6,
5115
+ reason: MARKDOWN_VALIDATION_TARGET_DIAGNOSTICS.MISSING_OR_UNRELATED_SCOPE
5116
+ }
5117
+ };
5118
+ }
5119
+ function getExcludeGlobs(projectRoot) {
5120
+ if (projectRoot === void 0) return [];
5121
+ const reader = createIgnoreSourceReader(projectRoot, {
5122
+ ignoreSourceFilename: IGNORE_SOURCE_FILENAME_DEFAULT,
5123
+ specTreeRootSegment: SPEC_TREE_CONFIG.ROOT_DIRECTORY
5124
+ });
5125
+ return reader.entries().map((entry) => `${entry.segment}/**`);
5126
+ }
5127
+ var DATA_URI_PATTERN = /\bdata:/;
5128
+ function parseErrorLine(line) {
5129
+ if (DATA_URI_PATTERN.test(line)) {
5130
+ return null;
5131
+ }
5132
+ const match = ERROR_LINE_PATTERN.exec(line);
5133
+ if (!match) {
5134
+ return null;
5135
+ }
5136
+ const [, file, lineStr, detail] = match;
5137
+ return {
5138
+ file,
5139
+ line: parseInt(lineStr, 10),
5140
+ detail
5141
+ };
5142
+ }
5143
+ async function validateMarkdown(options) {
5144
+ const { targets, projectRoot } = options;
5145
+ const errors = [];
5146
+ const excludeGlobs = getExcludeGlobs(projectRoot);
5147
+ for (const target of targets) {
5148
+ const directory = targetDirectory(target);
5149
+ const dirName = markdownlintConfigDirectoryName(directory, projectRoot);
5150
+ const config = buildMarkdownlintConfig(dirName);
5151
+ const dirErrors = await validateTarget(target, config, projectRoot, excludeGlobs);
5152
+ errors.push(...dirErrors);
5153
+ }
5154
+ return {
5155
+ success: errors.length === 0,
5156
+ errors
5157
+ };
5158
+ }
5159
+ async function validateTarget(target, config, projectRoot, ignoreGlobs = []) {
5160
+ const errors = [];
5161
+ const directory = targetDirectory(target);
5162
+ const argv = target.kind === MARKDOWN_VALIDATION_TARGET_KIND.FILE ? [basename(target.path)] : [MARKDOWN_DIRECTORY_GLOB];
5163
+ const { customRules, ...markdownlintConfig } = config;
5164
+ const optionsOverride = {
5165
+ config: {
5166
+ ...markdownlintConfig,
5167
+ "relative-links": projectRoot ? { root_path: projectRoot } : true
5168
+ },
5169
+ customRules,
5170
+ noProgress: true,
5171
+ noBanner: true,
5172
+ ...ignoreGlobs.length > 0 ? { ignores: ignoreGlobs } : {}
5173
+ };
5174
+ await markdownlintMain({
5175
+ directory,
5176
+ argv,
5177
+ optionsOverride,
5178
+ noImport: true,
5179
+ logMessage: () => {
5180
+ },
5181
+ logError: (message) => {
5182
+ const parsed = parseErrorLine(message);
5183
+ if (parsed) {
5184
+ errors.push({
5185
+ ...parsed,
5186
+ file: join11(directory, parsed.file)
5187
+ });
5188
+ }
5189
+ }
5190
+ });
5191
+ return errors;
5192
+ }
5193
+ function targetDirectory(target) {
5194
+ return target.kind === MARKDOWN_VALIDATION_TARGET_KIND.FILE ? dirname3(target.path) : target.path;
5195
+ }
5196
+ function hasMarkdownExtension(path6) {
5197
+ const lastDot = path6.lastIndexOf(".");
5198
+ if (lastDot < 0) return false;
5199
+ return MARKDOWN_FILE_EXTENSIONS.has(path6.slice(lastDot).toLowerCase());
5200
+ }
5201
+ function isExistingDirectory(path6, deps) {
5202
+ try {
5203
+ return deps.statSync(path6).isDirectory();
5204
+ } catch {
5205
+ return false;
5206
+ }
5207
+ }
5208
+ function isExistingFile(path6, deps) {
5209
+ try {
5210
+ return deps.statSync(path6).isFile();
5211
+ } catch {
5212
+ return false;
5213
+ }
5214
+ }
5215
+ function markdownlintConfigDirectoryName(directory, projectRoot) {
5216
+ if (projectRoot !== void 0) {
5217
+ const [rootSegment] = pathRelative(projectRoot, directory).split(/[\\/]/);
5218
+ if (MD024_DISABLED_DIRECTORIES.includes(rootSegment)) {
5219
+ return rootSegment;
5220
+ }
5221
+ }
5222
+ return basename(directory);
5223
+ }
5224
+
5225
+ // src/commands/validation/messages.ts
5226
+ var VALIDATION_STAGE_DISPLAY_NAMES = {
5227
+ CIRCULAR: "Circular dependencies",
5228
+ KNIP: "Knip",
5229
+ ESLINT: "ESLint",
5230
+ TYPESCRIPT: "TypeScript",
5231
+ MARKDOWN: "Markdown",
5232
+ LITERAL: "Literal"
5233
+ };
5234
+ var VALIDATION_SKIP_LABELS = {
5235
+ VERB: "Skipping",
5236
+ LITERAL_REASON: "skip-literal",
5237
+ TYPESCRIPT_ABSENT_REASON: "TypeScript not detected in project",
5238
+ VALIDATION_PATHS_NO_TARGETS_REASON: "validation paths matched no files",
5239
+ MARKDOWN_NO_SCOPE_REASON: "no markdown files in --files scope",
5240
+ MARKDOWN_NO_DEFAULT_DIRECTORIES_REASON: "no spx/ or docs/ directories found"
5241
+ };
5242
+ var VALIDATION_COMMAND_OUTPUT = {
5243
+ CIRCULAR_FOUND: `${VALIDATION_STAGE_DISPLAY_NAMES.CIRCULAR} found`,
5244
+ CIRCULAR_NONE_FOUND: `${VALIDATION_STAGE_DISPLAY_NAMES.CIRCULAR}: \u2713 None found`,
5245
+ KNIP_CONFIG_ERROR: `${VALIDATION_STAGE_DISPLAY_NAMES.KNIP}: \u2717 config error`,
5246
+ KNIP_DISABLED: `${VALIDATION_STAGE_DISPLAY_NAMES.KNIP}: skipped (disabled by validation.knip.enabled)`,
5247
+ KNIP_SUCCESS: `${VALIDATION_STAGE_DISPLAY_NAMES.KNIP}: \u2713 No unused code found`,
5248
+ KNIP_FAILURE: "Unused code found",
5249
+ ESLINT_SUCCESS: `${VALIDATION_STAGE_DISPLAY_NAMES.ESLINT}: \u2713 No errors found`,
5250
+ ESLINT_FAILURE: `${VALIDATION_STAGE_DISPLAY_NAMES.ESLINT} validation failed`,
5251
+ ESLINT_MISSING_CONFIG: "ESLint config not found: project has tsconfig.json but no eslint.config.{ts,js,mjs,cjs}",
5252
+ TYPESCRIPT_SUCCESS: `${VALIDATION_STAGE_DISPLAY_NAMES.TYPESCRIPT}: \u2713 No type errors`,
5253
+ TYPESCRIPT_FAILURE: `${VALIDATION_STAGE_DISPLAY_NAMES.TYPESCRIPT} validation failed`,
5254
+ MARKDOWN_NO_ISSUES: `${VALIDATION_STAGE_DISPLAY_NAMES.MARKDOWN}: No issues found`,
5255
+ MARKDOWN_ERROR_SUMMARY_SUFFIX: "error(s) found"
5256
+ };
5257
+ var LITERAL_SKIP_OUTPUT = `${VALIDATION_STAGE_DISPLAY_NAMES.LITERAL}: skipped (--${VALIDATION_SKIP_LABELS.LITERAL_REASON})`;
5258
+ var LITERAL_SKIP_JSON_OUTPUT = JSON.stringify({
5259
+ skipped: true,
5260
+ reason: VALIDATION_SKIP_LABELS.LITERAL_REASON
5261
+ });
5262
+ function formatTypeScriptAbsentSkipMessage(stageName) {
5263
+ return `\u23ED ${VALIDATION_SKIP_LABELS.VERB} ${stageName} (${VALIDATION_SKIP_LABELS.TYPESCRIPT_ABSENT_REASON})`;
5264
+ }
5265
+ function formatValidationPathsNoTargetsSkipMessage(stageName) {
5266
+ return `\u23ED ${VALIDATION_SKIP_LABELS.VERB} ${stageName} (${VALIDATION_SKIP_LABELS.VALIDATION_PATHS_NO_TARGETS_REASON})`;
5267
+ }
5268
+
5269
+ // src/commands/validation/markdown.ts
5270
+ var MARKDOWN_COMMAND_OUTPUT = {
5271
+ ERROR_SUMMARY_SUFFIX: VALIDATION_COMMAND_OUTPUT.MARKDOWN_ERROR_SUMMARY_SUFFIX,
5272
+ NO_ISSUES: VALIDATION_COMMAND_OUTPUT.MARKDOWN_NO_ISSUES,
5273
+ SKIPPED_FILE_SCOPE_PREFIX: "Markdown skipped file scope"
5274
+ };
5275
+ var MARKDOWN_CONFIG_ERROR_MESSAGE = `${VALIDATION_STAGE_DISPLAY_NAMES.MARKDOWN}: \u2717 config error`;
5276
+ async function markdownCommand(options) {
5277
+ const { cwd, files, quiet } = options;
5278
+ const startTime = Date.now();
5279
+ const loaded = await resolveConfig(cwd, [validationConfigDescriptor]);
5280
+ if (!loaded.ok) {
5281
+ return {
5282
+ exitCode: 1,
5283
+ output: `${MARKDOWN_CONFIG_ERROR_MESSAGE} \u2014 ${loaded.error}`,
5284
+ durationMs: Date.now() - startTime
5285
+ };
5286
+ }
5287
+ const validationConfig = loaded.value[validationConfigDescriptor.section];
5288
+ const pathFilter = validationPathFilterForTool(
5289
+ validationConfig.paths,
5290
+ VALIDATION_PATH_TOOL_SUBSECTIONS.MARKDOWN
5291
+ );
5292
+ const targetResolutions = files && files.length > 0 ? files.map((filePath) => resolveMarkdownValidationTarget(filePath)) : void 0;
5293
+ const unfilteredTargets = targetResolutions !== void 0 ? targetResolutions.map((resolution) => resolution.target).filter((target) => target !== void 0) : getDefaultDirectories(cwd).map((path6) => ({
5294
+ kind: MARKDOWN_VALIDATION_TARGET_KIND.DIRECTORY,
5295
+ path: path6
5296
+ }));
5297
+ const targets = unfilteredTargets.filter(
5298
+ (target) => pathPassesValidationFilter(relative4(cwd, target.path), pathFilter)
5299
+ );
5300
+ const skippedTargets = targetResolutions === void 0 ? [] : targetResolutions.map((resolution) => resolution.skipped).filter((skipped) => skipped !== void 0);
5301
+ const skippedOutput = quiet ? [] : skippedTargets.map(formatSkippedFileScope);
5302
+ if (targets.length === 0) {
5303
+ const reason = files && files.length > 0 ? VALIDATION_SKIP_LABELS.MARKDOWN_NO_SCOPE_REASON : VALIDATION_SKIP_LABELS.MARKDOWN_NO_DEFAULT_DIRECTORIES_REASON;
5304
+ const output = quiet ? "" : [
5305
+ ...skippedOutput,
5306
+ `${VALIDATION_STAGE_DISPLAY_NAMES.MARKDOWN}: skipped (${reason})`
5307
+ ].join("\n");
5308
+ return { exitCode: 0, output, durationMs: Date.now() - startTime };
5309
+ }
5310
+ const result = await validateMarkdown({
5311
+ targets,
5312
+ projectRoot: cwd
5313
+ });
5314
+ const durationMs = Date.now() - startTime;
5315
+ if (result.success) {
5316
+ const output = quiet ? "" : [...skippedOutput, MARKDOWN_COMMAND_OUTPUT.NO_ISSUES].join("\n");
5317
+ return { exitCode: 0, output, durationMs };
5318
+ } else {
5319
+ const errorLines = result.errors.map(
5320
+ (error) => ` ${error.file}:${error.line} ${error.detail}`
5321
+ );
5322
+ const output = [
5323
+ ...skippedOutput,
5324
+ `Markdown: ${result.errors.length} ${MARKDOWN_COMMAND_OUTPUT.ERROR_SUMMARY_SUFFIX}`,
5325
+ ...errorLines
5326
+ ].join("\n");
5327
+ return { exitCode: 1, output, durationMs };
5328
+ }
5329
+ }
5330
+ function formatSkippedFileScope(target) {
5331
+ return `${MARKDOWN_COMMAND_OUTPUT.SKIPPED_FILE_SCOPE_PREFIX}: ${target.path} (${target.reason})`;
5332
+ }
5333
+
5334
+ // src/validation/languages/markdown.ts
5335
+ var MARKDOWN_LANGUAGE_NAME = "markdown";
5336
+ var markdownValidationLanguage = {
5337
+ name: MARKDOWN_LANGUAGE_NAME,
5338
+ stages: [
5339
+ {
5340
+ name: VALIDATION_STAGE_DISPLAY_NAMES.MARKDOWN,
5341
+ failsPipeline: true,
5342
+ run: (context) => markdownCommand({ cwd: context.cwd, files: context.files, quiet: context.quiet })
5343
+ }
5344
+ ]
5345
+ };
5346
+
5347
+ // node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
5348
+ function createScanner(text, ignoreTrivia = false) {
5349
+ const len = text.length;
5350
+ let pos = 0, value = "", tokenOffset = 0, token = 16, lineNumber = 0, lineStartOffset = 0, tokenLineStartOffset = 0, prevTokenLineStartOffset = 0, scanError = 0;
5351
+ function scanHexDigits(count, exact) {
5352
+ let digits = 0;
5353
+ let value2 = 0;
5354
+ while (digits < count || !exact) {
5355
+ let ch = text.charCodeAt(pos);
5356
+ if (ch >= 48 && ch <= 57) {
5357
+ value2 = value2 * 16 + ch - 48;
5358
+ } else if (ch >= 65 && ch <= 70) {
5359
+ value2 = value2 * 16 + ch - 65 + 10;
5360
+ } else if (ch >= 97 && ch <= 102) {
5361
+ value2 = value2 * 16 + ch - 97 + 10;
5362
+ } else {
5363
+ break;
5364
+ }
5365
+ pos++;
5366
+ digits++;
5367
+ }
5368
+ if (digits < count) {
4880
5369
  value2 = -1;
4881
5370
  }
4882
5371
  return value2;
@@ -5715,8 +6204,8 @@ var ParseErrorCode;
5715
6204
  })(ParseErrorCode || (ParseErrorCode = {}));
5716
6205
 
5717
6206
  // src/validation/config/scope.ts
5718
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "fs";
5719
- import { isAbsolute as isAbsolute3, join as join11 } from "path";
6207
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2 } from "fs";
6208
+ import { isAbsolute as isAbsolute3, join as join12 } from "path";
5720
6209
  var TSCONFIG_FILES = {
5721
6210
  full: "tsconfig.json",
5722
6211
  production: "tsconfig.production.json"
@@ -5726,11 +6215,11 @@ var GLOB_MARKER = "*";
5726
6215
  var HIDDEN_PATH_PREFIX = ".";
5727
6216
  var defaultScopeDeps = {
5728
6217
  readFileSync: readFileSync2,
5729
- existsSync: existsSync2,
6218
+ existsSync: existsSync3,
5730
6219
  readdirSync
5731
6220
  };
5732
6221
  function resolveProjectPath(projectRoot, path6) {
5733
- return isAbsolute3(path6) ? path6 : join11(projectRoot, path6);
6222
+ return isAbsolute3(path6) ? path6 : join12(projectRoot, path6);
5734
6223
  }
5735
6224
  function parseTypeScriptConfig(configPath, deps = defaultScopeDeps) {
5736
6225
  try {
@@ -5774,7 +6263,7 @@ function hasTypeScriptFilesRecursive(dirPath, maxDepth = 2, deps = defaultScopeD
5774
6263
  if (hasDirectTsFiles) return true;
5775
6264
  const subdirs = items.filter((item) => item.isDirectory() && !item.name.startsWith("."));
5776
6265
  for (const subdir of subdirs.slice(0, 5)) {
5777
- if (hasTypeScriptFilesRecursive(join11(dirPath, subdir.name), maxDepth - 1, deps)) {
6266
+ if (hasTypeScriptFilesRecursive(join12(dirPath, subdir.name), maxDepth - 1, deps)) {
5778
6267
  return true;
5779
6268
  }
5780
6269
  }
@@ -5797,7 +6286,7 @@ function getTopLevelDirectoriesWithTypeScript(config, projectRoot, deps = defaul
5797
6286
  });
5798
6287
  if (!isExcluded) {
5799
6288
  try {
5800
- const hasTypeScriptFiles = hasTypeScriptFilesRecursive(join11(projectRoot, dir), 2, deps);
6289
+ const hasTypeScriptFiles = hasTypeScriptFilesRecursive(join12(projectRoot, dir), 2, deps);
5801
6290
  if (hasTypeScriptFiles) {
5802
6291
  directories.add(dir);
5803
6292
  }
@@ -5828,13 +6317,13 @@ function getLiteralTopLevelPatternDirectory(pattern) {
5828
6317
  function filterActiveIncludePatterns(patterns, excludePatterns, projectRoot, deps) {
5829
6318
  return patterns.filter((pattern) => {
5830
6319
  const topLevelDir = getLiteralTopLevelPatternDirectory(pattern);
5831
- return topLevelDir === null || deps.existsSync(join11(projectRoot, topLevelDir));
6320
+ return topLevelDir === null || deps.existsSync(join12(projectRoot, topLevelDir));
5832
6321
  }).filter((pattern) => pathPassesValidationFilter(pattern, { exclude: excludePatterns }));
5833
6322
  }
5834
6323
  function getValidationDirectories(scope, projectRoot, deps = defaultScopeDeps) {
5835
6324
  const config = resolveTypeScriptConfig(scope, projectRoot, deps);
5836
6325
  const configDirectories = getTopLevelDirectoriesWithTypeScript(config, projectRoot, deps);
5837
- const existingDirectories = configDirectories.filter((dir) => deps.existsSync(join11(projectRoot, dir)));
6326
+ const existingDirectories = configDirectories.filter((dir) => deps.existsSync(join12(projectRoot, dir)));
5838
6327
  return existingDirectories;
5839
6328
  }
5840
6329
  function getTypeScriptScope(scope, projectRoot, deps = defaultScopeDeps) {
@@ -5991,7 +6480,7 @@ function detectTypeScript(projectRoot, deps = defaultLanguageDetectionDeps) {
5991
6480
 
5992
6481
  // src/validation/steps/circular.ts
5993
6482
  import madge from "madge";
5994
- import { join as join12 } from "path";
6483
+ import { join as join13 } from "path";
5995
6484
  var CIRCULAR_DEPS_KEYS = {
5996
6485
  MADGE: "madge"
5997
6486
  };
@@ -6000,87 +6489,36 @@ var defaultCircularDeps = {
6000
6489
  };
6001
6490
  async function validateCircularDependencies(scope, typescriptScope, projectRoot, deps = defaultCircularDeps) {
6002
6491
  try {
6003
- const analyzeDirectories = typescriptScope.directories.map((directory) => join12(projectRoot, directory));
6004
- if (analyzeDirectories.length === 0) {
6005
- return { success: true };
6006
- }
6007
- const tsConfigFile = join12(projectRoot, TSCONFIG_FILES[scope]);
6008
- const excludeRegExps = typescriptScope.excludePatterns.map((pattern) => {
6009
- const cleanPattern = pattern.replace(/\/\*\*?\/\*$/, "");
6010
- const escaped = cleanPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6011
- return new RegExp(escaped);
6012
- });
6013
- const result = await deps.madge(analyzeDirectories, {
6014
- baseDir: projectRoot,
6015
- fileExtensions: ["ts", "tsx"],
6016
- tsConfig: tsConfigFile,
6017
- excludeRegExp: excludeRegExps
6018
- });
6019
- const circular = result.circular();
6020
- if (circular.length === 0) {
6021
- return { success: true };
6022
- } else {
6023
- return {
6024
- success: false,
6025
- error: `Found ${circular.length} circular dependency cycle(s)`,
6026
- circularDependencies: circular
6027
- };
6028
- }
6029
- } catch (error) {
6030
- const errorMessage = error instanceof Error ? error.message : String(error);
6031
- return { success: false, error: errorMessage };
6032
- }
6033
- }
6034
-
6035
- // src/commands/validation/messages.ts
6036
- var VALIDATION_PIPELINE = {
6037
- TOTAL_STEPS: 6
6038
- };
6039
- var VALIDATION_STAGE_DISPLAY_NAMES = {
6040
- CIRCULAR: "Circular dependencies",
6041
- KNIP: "Knip",
6042
- ESLINT: "ESLint",
6043
- TYPESCRIPT: "TypeScript",
6044
- MARKDOWN: "Markdown",
6045
- LITERAL: "Literal"
6046
- };
6047
- var VALIDATION_SKIP_LABELS = {
6048
- VERB: "Skipping",
6049
- LITERAL_REASON: "skip-literal",
6050
- TYPESCRIPT_ABSENT_REASON: "TypeScript not detected in project",
6051
- VALIDATION_PATHS_NO_TARGETS_REASON: "validation paths matched no files",
6052
- MARKDOWN_NO_SCOPE_REASON: "no markdown files in --files scope",
6053
- MARKDOWN_NO_DEFAULT_DIRECTORIES_REASON: "no spx/ or docs/ directories found"
6054
- };
6055
- var VALIDATION_COMMAND_OUTPUT = {
6056
- CIRCULAR_FOUND: `${VALIDATION_STAGE_DISPLAY_NAMES.CIRCULAR} found`,
6057
- CIRCULAR_NONE_FOUND: `${VALIDATION_STAGE_DISPLAY_NAMES.CIRCULAR}: \u2713 None found`,
6058
- KNIP_CONFIG_ERROR: `${VALIDATION_STAGE_DISPLAY_NAMES.KNIP}: \u2717 config error`,
6059
- KNIP_DISABLED: `${VALIDATION_STAGE_DISPLAY_NAMES.KNIP}: skipped (disabled by validation.knip.enabled)`,
6060
- KNIP_SUCCESS: `${VALIDATION_STAGE_DISPLAY_NAMES.KNIP}: \u2713 No unused code found`,
6061
- KNIP_FAILURE: "Unused code found",
6062
- ESLINT_SUCCESS: `${VALIDATION_STAGE_DISPLAY_NAMES.ESLINT}: \u2713 No errors found`,
6063
- ESLINT_FAILURE: `${VALIDATION_STAGE_DISPLAY_NAMES.ESLINT} validation failed`,
6064
- ESLINT_MISSING_CONFIG: "ESLint config not found: project has tsconfig.json but no eslint.config.{ts,js,mjs,cjs}",
6065
- TYPESCRIPT_SUCCESS: `${VALIDATION_STAGE_DISPLAY_NAMES.TYPESCRIPT}: \u2713 No type errors`,
6066
- TYPESCRIPT_FAILURE: `${VALIDATION_STAGE_DISPLAY_NAMES.TYPESCRIPT} validation failed`,
6067
- MARKDOWN_NO_ISSUES: `${VALIDATION_STAGE_DISPLAY_NAMES.MARKDOWN}: No issues found`,
6068
- MARKDOWN_ERROR_SUMMARY_SUFFIX: "error(s) found"
6069
- };
6070
- var LITERAL_SKIP_OUTPUT = `${VALIDATION_STAGE_DISPLAY_NAMES.LITERAL}: skipped (--${VALIDATION_SKIP_LABELS.LITERAL_REASON})`;
6071
- var LITERAL_SKIP_JSON_OUTPUT = JSON.stringify({
6072
- skipped: true,
6073
- reason: VALIDATION_SKIP_LABELS.LITERAL_REASON
6074
- });
6075
- var VALIDATION_STEP_LINE_PATTERN = new RegExp(
6076
- String.raw`^\[(\d+)\/${VALIDATION_PIPELINE.TOTAL_STEPS}\]`,
6077
- "gm"
6078
- );
6079
- function formatTypeScriptAbsentSkipMessage(stageName) {
6080
- return `\u23ED ${VALIDATION_SKIP_LABELS.VERB} ${stageName} (${VALIDATION_SKIP_LABELS.TYPESCRIPT_ABSENT_REASON})`;
6081
- }
6082
- function formatValidationPathsNoTargetsSkipMessage(stageName) {
6083
- return `\u23ED ${VALIDATION_SKIP_LABELS.VERB} ${stageName} (${VALIDATION_SKIP_LABELS.VALIDATION_PATHS_NO_TARGETS_REASON})`;
6492
+ const analyzeDirectories = typescriptScope.directories.map((directory) => join13(projectRoot, directory));
6493
+ if (analyzeDirectories.length === 0) {
6494
+ return { success: true };
6495
+ }
6496
+ const tsConfigFile = join13(projectRoot, TSCONFIG_FILES[scope]);
6497
+ const excludeRegExps = typescriptScope.excludePatterns.map((pattern) => {
6498
+ const cleanPattern = pattern.replace(/\/\*\*?\/\*$/, "");
6499
+ const escaped = cleanPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6500
+ return new RegExp(escaped);
6501
+ });
6502
+ const result = await deps.madge(analyzeDirectories, {
6503
+ baseDir: projectRoot,
6504
+ fileExtensions: ["ts", "tsx"],
6505
+ tsConfig: tsConfigFile,
6506
+ excludeRegExp: excludeRegExps
6507
+ });
6508
+ const circular = result.circular();
6509
+ if (circular.length === 0) {
6510
+ return { success: true };
6511
+ } else {
6512
+ return {
6513
+ success: false,
6514
+ error: `Found ${circular.length} circular dependency cycle(s)`,
6515
+ circularDependencies: circular
6516
+ };
6517
+ }
6518
+ } catch (error) {
6519
+ const errorMessage = error instanceof Error ? error.message : String(error);
6520
+ return { success: false, error: errorMessage };
6521
+ }
6084
6522
  }
6085
6523
 
6086
6524
  // src/commands/validation/circular.ts
@@ -6134,36 +6572,11 @@ ${cycles}`;
6134
6572
  }
6135
6573
  }
6136
6574
 
6137
- // src/commands/validation/format.ts
6138
- var DURATION_THRESHOLD_MS = 1e3;
6139
- var VALIDATION_SYMBOLS = {
6140
- SUCCESS: "\u2713",
6141
- FAILURE: "\u2717"
6142
- };
6143
- var VALIDATION_SUMMARY_STATUS = {
6144
- PASSED: "passed",
6145
- FAILED: "failed"
6146
- };
6147
- function formatDuration(ms) {
6148
- if (ms < DURATION_THRESHOLD_MS) {
6149
- return `${ms}ms`;
6150
- }
6151
- const seconds = ms / 1e3;
6152
- return `${seconds.toFixed(1)}s`;
6153
- }
6154
- function formatSummary(options) {
6155
- const { success, totalDurationMs } = options;
6156
- const symbol = success ? VALIDATION_SYMBOLS.SUCCESS : VALIDATION_SYMBOLS.FAILURE;
6157
- const status = success ? VALIDATION_SUMMARY_STATUS.PASSED : VALIDATION_SUMMARY_STATUS.FAILED;
6158
- const duration = formatDuration(totalDurationMs);
6159
- return `${symbol} Validation ${status} (${duration} total)`;
6160
- }
6161
-
6162
6575
  // src/validation/steps/knip.ts
6163
- import { existsSync as existsSync3 } from "fs";
6576
+ import { existsSync as existsSync4 } from "fs";
6164
6577
  import { mkdtemp, rm, writeFile as writeFile2 } from "fs/promises";
6165
6578
  import { tmpdir } from "os";
6166
- import { isAbsolute as isAbsolute4, join as join13 } from "path";
6579
+ import { isAbsolute as isAbsolute4, join as join14 } from "path";
6167
6580
 
6168
6581
  // src/lib/process-lifecycle/exit-codes.ts
6169
6582
  var SIGINT_EXIT_CODE = 130;
@@ -6317,7 +6730,7 @@ var KNIP_COMMAND_TOKENS = {
6317
6730
  USE_TSCONFIG_FILES_FLAG: "--use-tsconfig-files"
6318
6731
  };
6319
6732
  var defaultKnipDeps = {
6320
- existsSync: existsSync3,
6733
+ existsSync: existsSync4,
6321
6734
  mkdtemp,
6322
6735
  rm,
6323
6736
  writeFile: writeFile2
@@ -6337,7 +6750,7 @@ async function validateKnip2(context, runner = defaultKnipProcessRunner, deps =
6337
6750
  }
6338
6751
  async function runKnipSubprocess(projectRoot, typescriptScope, runner, deps) {
6339
6752
  const scopedTsconfig = typescriptScope.filteredByValidationPaths ? await createScopedKnipTsconfig(projectRoot, typescriptScope, deps) : void 0;
6340
- const localBin = join13(projectRoot, "node_modules", ".bin", "knip");
6753
+ const localBin = join14(projectRoot, "node_modules", ".bin", "knip");
6341
6754
  const binary = deps.existsSync(localBin) ? localBin : "npx";
6342
6755
  const baseArgs = scopedTsconfig === void 0 ? [] : [
6343
6756
  KNIP_COMMAND_TOKENS.USE_TSCONFIG_FILES_FLAG,
@@ -6392,12 +6805,12 @@ async function runKnipSubprocess(projectRoot, typescriptScope, runner, deps) {
6392
6805
  });
6393
6806
  }
6394
6807
  async function createScopedKnipTsconfig(projectRoot, typescriptScope, deps) {
6395
- const tempDir = await deps.mkdtemp(join13(tmpdir(), "validate-knip-"));
6396
- const configPath = join13(tempDir, TSCONFIG_FILES.full);
6397
- const toProjectPathPattern = (pattern) => isAbsolute4(pattern) ? pattern : join13(projectRoot, pattern);
6808
+ const tempDir = await deps.mkdtemp(join14(tmpdir(), "validate-knip-"));
6809
+ const configPath = join14(tempDir, TSCONFIG_FILES.full);
6810
+ const toProjectPathPattern = (pattern) => isAbsolute4(pattern) ? pattern : join14(projectRoot, pattern);
6398
6811
  const project = typescriptScope.filePatterns.length > 0 ? typescriptScope.filePatterns : typescriptScope.directories.map((directory) => `${directory}/**/*.{js,ts,tsx}`);
6399
6812
  const config = {
6400
- extends: join13(projectRoot, TSCONFIG_FILES.full),
6813
+ extends: join14(projectRoot, TSCONFIG_FILES.full),
6401
6814
  include: project.map(toProjectPathPattern),
6402
6815
  exclude: typescriptScope.excludePatterns.map(toProjectPathPattern)
6403
6816
  };
@@ -6446,13 +6859,13 @@ async function knipCommand(options) {
6446
6859
  }
6447
6860
 
6448
6861
  // src/validation/steps/eslint.ts
6449
- import { existsSync as existsSync5 } from "fs";
6450
- import { join as join15 } from "path";
6862
+ import { existsSync as existsSync6 } from "fs";
6863
+ import { join as join16 } from "path";
6451
6864
 
6452
6865
  // src/validation/lint-policy.ts
6453
6866
  import { execFileSync } from "child_process";
6454
- import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "fs";
6455
- import { join as join14 } from "path";
6867
+ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
6868
+ import { join as join15 } from "path";
6456
6869
 
6457
6870
  // src/validation/lint-policy-constants.ts
6458
6871
  var LINT_POLICY_MANIFESTS = {
@@ -6499,17 +6912,17 @@ var DEPRECATED_SPEC_NODE_SUFFIX_PATTERN = /\.(capability|feature|story)$/;
6499
6912
  var BASE_BRANCH_REFS = [LINT_POLICY_BASE_REFS.REMOTE_MAIN, LINT_POLICY_BASE_REFS.LOCAL_MAIN];
6500
6913
  function readManifest(productDir, file, key) {
6501
6914
  return parseLintPolicyManifest(
6502
- readFileSync3(join14(productDir, file), "utf-8"),
6915
+ readFileSync3(join15(productDir, file), "utf-8"),
6503
6916
  file,
6504
6917
  key
6505
6918
  );
6506
6919
  }
6507
6920
  function manifestExists(productDir, file) {
6508
- return existsSync4(join14(productDir, file));
6921
+ return existsSync5(join15(productDir, file));
6509
6922
  }
6510
6923
  function findDeprecatedSpecNodePath(productDir) {
6511
6924
  function visit2(relativeDirectory) {
6512
- const absoluteDirectory = join14(productDir, relativeDirectory);
6925
+ const absoluteDirectory = join15(productDir, relativeDirectory);
6513
6926
  for (const entry of readdirSync2(absoluteDirectory, { withFileTypes: true })) {
6514
6927
  if (!entry.isDirectory()) {
6515
6928
  continue;
@@ -6525,8 +6938,8 @@ function findDeprecatedSpecNodePath(productDir) {
6525
6938
  }
6526
6939
  return void 0;
6527
6940
  }
6528
- const specTreeRootPath = join14(productDir, SPEC_TREE_ROOT);
6529
- if (!existsSync4(specTreeRootPath)) {
6941
+ const specTreeRootPath = join15(productDir, SPEC_TREE_ROOT);
6942
+ if (!existsSync5(specTreeRootPath)) {
6530
6943
  return void 0;
6531
6944
  }
6532
6945
  return visit2(SPEC_TREE_ROOT);
@@ -6551,8 +6964,8 @@ function assertManifestEntries(productDir, file, entries, suffixPattern, suffixD
6551
6964
  if (!suffixPattern.test(entry)) {
6552
6965
  throw new Error(`${file} entry must ${suffixDescription}: ${entry}`);
6553
6966
  }
6554
- const absoluteEntry = join14(productDir, entry);
6555
- if (!existsSync4(absoluteEntry) || !statSync(absoluteEntry).isDirectory()) {
6967
+ const absoluteEntry = join15(productDir, entry);
6968
+ if (!existsSync5(absoluteEntry) || !statSync2(absoluteEntry).isDirectory()) {
6556
6969
  throw new Error(`${file} entry does not exist as a directory: ${entry}`);
6557
6970
  }
6558
6971
  }
@@ -6811,8 +7224,8 @@ async function validateESLint(context, runner = defaultEslintProcessRunner, outp
6811
7224
  scopeConfig: context.scopeConfig
6812
7225
  });
6813
7226
  return new Promise((resolve6) => {
6814
- const localBin = join15(projectRoot, ...ESLINT_LOCAL_BIN_SEGMENTS);
6815
- const binary = existsSync5(localBin) ? localBin : "npx";
7227
+ const localBin = join16(projectRoot, ...ESLINT_LOCAL_BIN_SEGMENTS);
7228
+ const binary = existsSync6(localBin) ? localBin : "npx";
6816
7229
  const spawnArgs = binary === "npx" ? eslintArgs : eslintArgs.slice(1);
6817
7230
  const eslintProcess = spawnManagedSubprocess(runner, binary, spawnArgs, {
6818
7231
  cwd: projectRoot
@@ -6912,8 +7325,8 @@ async function lintCommand(options) {
6912
7325
  }
6913
7326
 
6914
7327
  // src/validation/literal/index.ts
6915
- import { readFile as readFile9 } from "fs/promises";
6916
- import { isAbsolute as isAbsolute5, relative as relative5, resolve as resolve5 } from "path";
7328
+ import { readFile as readFile8 } from "fs/promises";
7329
+ import { isAbsolute as isAbsolute5, relative as relative6, resolve as resolve5 } from "path";
6917
7330
 
6918
7331
  // src/lib/file-inclusion/predicates/ignore-source.ts
6919
7332
  var IGNORE_SOURCE_LAYER = "ignore-source";
@@ -6948,7 +7361,7 @@ var ignoreSourceLayer = makeLayer(
6948
7361
 
6949
7362
  // src/lib/file-inclusion/pipeline.ts
6950
7363
  import { readdir as readdir6 } from "fs/promises";
6951
- import { join as join16, relative as relative4, sep } from "path";
7364
+ import { join as join17, relative as relative5, sep } from "path";
6952
7365
  var EXPLICIT_OVERRIDE_LAYER = "explicit-override";
6953
7366
  function isNodeError2(err) {
6954
7367
  return err instanceof Error && "code" in err;
@@ -6966,11 +7379,11 @@ async function collectPaths(absoluteDir, projectRoot, result, artifactDirs) {
6966
7379
  for (const entry of dirEntries) {
6967
7380
  if (entry.isDirectory()) {
6968
7381
  if (artifactDirs.has(entry.name)) continue;
6969
- const absolutePath = join16(absoluteDir, entry.name);
7382
+ const absolutePath = join17(absoluteDir, entry.name);
6970
7383
  await collectPaths(absolutePath, projectRoot, result, artifactDirs);
6971
7384
  } else if (entry.isFile()) {
6972
- const absolutePath = join16(absoluteDir, entry.name);
6973
- const rel = relative4(projectRoot, absolutePath);
7385
+ const absolutePath = join17(absoluteDir, entry.name);
7386
+ const rel = relative5(projectRoot, absolutePath);
6974
7387
  result.push(sep === "/" ? rel : rel.split(sep).join("/"));
6975
7388
  }
6976
7389
  }
@@ -7079,782 +7492,551 @@ function collectLiterals(source, filename, options) {
7079
7492
  loc: true,
7080
7493
  range: true,
7081
7494
  comment: false,
7082
- jsx: true,
7083
- ecmaVersion: "latest",
7084
- sourceType: "module"
7085
- });
7086
- const out = [];
7087
- const ancestors = [];
7088
- walk(ast, { filename, isTestFixtureFile: isTestLikeFile(filename) }, ancestors, options, out);
7089
- return out;
7090
- }
7091
- function walk(node, context, ancestors, options, out) {
7092
- emitLiteral(node, context, ancestors, options, out);
7093
- const keys = options.visitorKeys[node.type];
7094
- if (!keys) {
7095
- return;
7096
- }
7097
- const skip = MODULE_NAMING_SKIP[node.type] ?? EMPTY_SKIP;
7098
- for (const key of keys) {
7099
- if (skip.has(key)) continue;
7100
- const child = node[key];
7101
- if (Array.isArray(child)) {
7102
- for (const item of child) {
7103
- if (isNode(item)) walkChild(item, node, context, ancestors, options, out);
7104
- }
7105
- } else if (isNode(child)) {
7106
- walkChild(child, node, context, ancestors, options, out);
7107
- }
7108
- }
7109
- }
7110
- function walkChild(node, parent, context, ancestors, options, out) {
7111
- ancestors.push({ node: parent });
7112
- try {
7113
- walk(node, context, ancestors, options, out);
7114
- } finally {
7115
- ancestors.pop();
7116
- }
7117
- }
7118
- function isNode(value) {
7119
- return typeof value === "object" && value !== null && typeof value.type === "string";
7120
- }
7121
- function emitLiteral(node, context, ancestors, options, out) {
7122
- if (node.type !== LITERAL_TYPE && node.type !== TEMPLATE_ELEMENT_TYPE) {
7123
- return;
7124
- }
7125
- if (isFixtureDataLiteral(context, ancestors)) {
7126
- return;
7127
- }
7128
- const line = node.loc?.start?.line ?? 0;
7129
- if (node.type === LITERAL_TYPE) {
7130
- if (typeof node.value === "string") {
7131
- if (node.value.length >= options.minStringLength) {
7132
- out.push({ kind: "string", value: node.value, loc: { file: context.filename, line } });
7133
- }
7134
- } else if (typeof node.value === "number") {
7135
- const raw = typeof node.raw === "string" ? node.raw : String(node.value);
7136
- if (isMeaningfulNumber(raw, options.minNumberDigits)) {
7137
- out.push({ kind: "number", value: String(node.value), loc: { file: context.filename, line } });
7138
- }
7139
- }
7140
- return;
7141
- }
7142
- if (node.type === TEMPLATE_ELEMENT_TYPE) {
7143
- const value = node.value;
7144
- const cooked = value?.cooked ?? "";
7145
- if (cooked.length >= options.minStringLength) {
7146
- out.push({ kind: "string", value: cooked, loc: { file: context.filename, line } });
7147
- }
7148
- }
7149
- }
7150
- function isTestLikeFile(filename) {
7151
- return filename.includes(TEST_PATH_SEGMENT) || filename.includes(WINDOWS_TEST_PATH_SEGMENT) || filename.includes(TEST_FILE_MARKER);
7152
- }
7153
- function isFixtureDataLiteral(context, ancestors) {
7154
- if (!context.isTestFixtureFile) {
7155
- return false;
7156
- }
7157
- return isInsideFixtureWriterArgument(ancestors) || isInsideFixtureDataVariable(ancestors);
7158
- }
7159
- function isInsideFixtureWriterArgument(ancestors) {
7160
- for (let index = ancestors.length - 1; index >= 0; index -= 1) {
7161
- const ancestor = ancestors[index];
7162
- if (ancestor.node.type !== CALL_EXPRESSION_TYPE) {
7163
- continue;
7164
- }
7165
- const callName = getCallName(ancestor.node);
7166
- if (callName === void 0 || !isFixtureWriterCall(callName)) {
7167
- continue;
7168
- }
7169
- if (hasNestedFunctionBetween(ancestors, index)) {
7170
- continue;
7171
- }
7172
- return true;
7173
- }
7174
- return false;
7175
- }
7176
- function isFixtureWriterCall(callName) {
7177
- return FIXTURE_WRITER_CALLS.has(callName);
7178
- }
7179
- function isInsideFixtureDataVariable(ancestors) {
7180
- for (let index = ancestors.length - 1; index >= 0; index -= 1) {
7181
- const ancestor = ancestors[index];
7182
- if (ancestor.node.type !== VARIABLE_DECLARATOR_TYPE) {
7183
- continue;
7184
- }
7185
- if (hasNestedFunctionBetween(ancestors, index)) {
7186
- continue;
7187
- }
7188
- const variableName = getFixtureDataDeclaratorName(ancestor.node);
7189
- if (variableName !== void 0 && isFixtureDataVariableName(variableName)) {
7190
- return true;
7191
- }
7192
- }
7193
- return false;
7194
- }
7195
- function hasNestedFunctionBetween(ancestors, ancestorIndex) {
7196
- for (let index = ancestorIndex + 1; index < ancestors.length; index += 1) {
7197
- if (FUNCTION_NODE_TYPES.has(ancestors[index].node.type)) {
7198
- return true;
7199
- }
7200
- }
7201
- return false;
7202
- }
7203
- function getFixtureDataDeclaratorName(node) {
7204
- const bindingName = getIdentifierName(node.id);
7205
- if (bindingName !== void 0) {
7206
- return bindingName;
7207
- }
7208
- return getIdentifierName(node.init);
7209
- }
7210
- function isFixtureDataVariableName(variableName) {
7211
- const segments = splitIdentifierName(variableName);
7212
- if (segments.length === 0) {
7213
- return false;
7214
- }
7215
- if (segments.some((segment) => FIXTURE_DATA_DIRECT_SEGMENTS.has(segment))) {
7216
- return true;
7217
- }
7218
- if (!segments.some((segment) => FIXTURE_DATA_ROLE_SEGMENTS.has(segment))) {
7219
- return false;
7220
- }
7221
- if (segments.every((segment) => FIXTURE_DATA_ROLE_SEGMENTS.has(segment))) {
7222
- return true;
7223
- }
7224
- const finalSegment = segments[segments.length - 1];
7225
- return finalSegment !== void 0 && FIXTURE_DATA_CONTEXT_SEGMENTS.has(finalSegment);
7226
- }
7227
- function splitIdentifierName(variableName) {
7228
- return variableName.split("_").flatMap((part) => part.match(IDENTIFIER_SEGMENT_PATTERN) ?? []).map((segment) => segment.toLowerCase());
7229
- }
7230
- function getCallName(node) {
7231
- const callee = node.callee;
7232
- if (!isNode(callee)) {
7233
- return void 0;
7234
- }
7235
- if (callee.type === IDENTIFIER_TYPE) {
7236
- return getIdentifierName(callee);
7237
- }
7238
- if (callee.type !== MEMBER_EXPRESSION_TYPE) {
7239
- return void 0;
7240
- }
7241
- const property = callee.property;
7242
- if (isNode(property)) {
7243
- return property.type === IDENTIFIER_TYPE ? getIdentifierName(property) : getLiteralString(property);
7244
- }
7245
- return void 0;
7246
- }
7247
- function getIdentifierName(value) {
7248
- if (!isNode(value) || value.type !== IDENTIFIER_TYPE) {
7249
- return void 0;
7250
- }
7251
- return typeof value.name === "string" ? value.name : void 0;
7252
- }
7253
- function getLiteralString(value) {
7254
- if (!isNode(value) || value.type !== LITERAL_TYPE) {
7255
- return void 0;
7256
- }
7257
- return typeof value.value === "string" ? value.value : void 0;
7258
- }
7259
- function isMeaningfulNumber(raw, minDigits) {
7260
- const digits = raw.replace(/[^0-9]/g, "");
7261
- return digits.length >= minDigits;
7262
- }
7263
- function buildIndex(occurrences) {
7264
- const map = /* @__PURE__ */ new Map();
7265
- for (const occ of occurrences) {
7266
- const key = makeKey(occ.kind, occ.value);
7267
- const existing = map.get(key);
7268
- if (existing) {
7269
- existing.push(occ.loc);
7270
- } else {
7271
- map.set(key, [occ.loc]);
7272
- }
7273
- }
7274
- return map;
7275
- }
7276
- function makeKey(kind, value) {
7277
- return `${kind}\0${value}`;
7278
- }
7279
- function splitKey(key) {
7280
- const idx = key.indexOf("\0");
7281
- return { kind: key.slice(0, idx), value: key.slice(idx + 1) };
7495
+ jsx: true,
7496
+ ecmaVersion: "latest",
7497
+ sourceType: "module"
7498
+ });
7499
+ const out = [];
7500
+ const ancestors = [];
7501
+ walk(ast, { filename, isTestFixtureFile: isTestLikeFile(filename) }, ancestors, options, out);
7502
+ return out;
7282
7503
  }
7283
- function detectReuse(input) {
7284
- const srcReuse = [];
7285
- const testDupe = [];
7286
- const testIndex = /* @__PURE__ */ new Map();
7287
- for (const [file, occurrences] of input.testOccurrencesByFile) {
7288
- for (const occ of occurrences) {
7289
- if (input.allowlist.has(occ.value)) continue;
7290
- const key = makeKey(occ.kind, occ.value);
7291
- let byFile = testIndex.get(key);
7292
- if (!byFile) {
7293
- byFile = /* @__PURE__ */ new Map();
7294
- testIndex.set(key, byFile);
7295
- }
7296
- const locsInFile = byFile.get(file);
7297
- if (locsInFile) locsInFile.push(occ.loc);
7298
- else byFile.set(file, [occ.loc]);
7299
- }
7504
+ function walk(node, context, ancestors, options, out) {
7505
+ emitLiteral(node, context, ancestors, options, out);
7506
+ const keys = options.visitorKeys[node.type];
7507
+ if (!keys) {
7508
+ return;
7300
7509
  }
7301
- for (const [key, byFile] of testIndex) {
7302
- const { kind, value } = splitKey(key);
7303
- const srcLocs = input.srcIndex.get(key);
7304
- const allTestLocs = [];
7305
- for (const locs of byFile.values()) allTestLocs.push(...locs);
7306
- if (srcLocs && srcLocs.length > 0) {
7307
- for (const testLoc of allTestLocs) {
7308
- srcReuse.push({
7309
- test: testLoc,
7310
- kind,
7311
- value,
7312
- src: srcLocs,
7313
- remediation: REMEDIATION.IMPORT_FROM_SOURCE
7314
- });
7315
- }
7316
- } else if (byFile.size >= 2) {
7317
- for (let i = 0; i < allTestLocs.length; i += 1) {
7318
- const otherTests = [...allTestLocs.slice(0, i), ...allTestLocs.slice(i + 1)];
7319
- testDupe.push({
7320
- test: allTestLocs[i],
7321
- kind,
7322
- value,
7323
- otherTests,
7324
- remediation: REMEDIATION.REFACTOR_TO_SOURCE_OR_GENERATOR
7325
- });
7510
+ const skip = MODULE_NAMING_SKIP[node.type] ?? EMPTY_SKIP;
7511
+ for (const key of keys) {
7512
+ if (skip.has(key)) continue;
7513
+ const child = node[key];
7514
+ if (Array.isArray(child)) {
7515
+ for (const item of child) {
7516
+ if (isNode(item)) walkChild(item, node, context, ancestors, options, out);
7326
7517
  }
7518
+ } else if (isNode(child)) {
7519
+ walkChild(child, node, context, ancestors, options, out);
7327
7520
  }
7328
7521
  }
7329
- return { srcReuse, testDupe };
7330
- }
7331
-
7332
- // src/validation/literal/walker.ts
7333
- var TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx"]);
7334
- var DECLARATION_SUFFIX = ".d.ts";
7335
- function isTypescriptSource(path6) {
7336
- if (path6.endsWith(DECLARATION_SUFFIX)) return false;
7337
- const ext = extensionOf(path6);
7338
- return TYPESCRIPT_EXTENSIONS.has(ext);
7339
- }
7340
- function extensionOf(name) {
7341
- const idx = name.lastIndexOf(".");
7342
- return idx === -1 ? "" : name.slice(idx);
7343
7522
  }
7344
- function isTestFile(relPath) {
7345
- return /\.test\.tsx?$/.test(relPath);
7523
+ function walkChild(node, parent, context, ancestors, options, out) {
7524
+ ancestors.push({ node: parent });
7525
+ try {
7526
+ walk(node, context, ancestors, options, out);
7527
+ } finally {
7528
+ ancestors.pop();
7529
+ }
7346
7530
  }
7347
-
7348
- // src/validation/literal/index.ts
7349
- var PATH_PREFIX_SEPARATOR2 = "/";
7350
- var DEFAULT_LITERAL_COLLECT_OPTIONS = {
7351
- visitorKeys: defaultVisitorKeys,
7352
- minStringLength: literalConfigDescriptor.defaults.minStringLength,
7353
- minNumberDigits: literalConfigDescriptor.defaults.minNumberDigits
7354
- };
7355
- function normalizePathPrefix2(prefix) {
7356
- const posix = prefix.split(/[\\/]/g).join(PATH_PREFIX_SEPARATOR2);
7357
- return posix.endsWith(PATH_PREFIX_SEPARATOR2) ? posix : `${posix}${PATH_PREFIX_SEPARATOR2}`;
7531
+ function isNode(value) {
7532
+ return typeof value === "object" && value !== null && typeof value.type === "string";
7358
7533
  }
7359
- function applyPathFilter(entries, pathConfig) {
7360
- if (pathConfig === void 0) {
7361
- return entries;
7534
+ function emitLiteral(node, context, ancestors, options, out) {
7535
+ if (node.type !== LITERAL_TYPE && node.type !== TEMPLATE_ELEMENT_TYPE) {
7536
+ return;
7362
7537
  }
7363
- const includePrefixes = (pathConfig.include ?? []).map(normalizePathPrefix2);
7364
- const excludePrefixes = (pathConfig.exclude ?? []).map(normalizePathPrefix2);
7365
- return entries.filter((entry) => {
7366
- if (includePrefixes.length > 0 && !includePrefixes.some((p) => entry.path.startsWith(p))) {
7367
- return false;
7368
- }
7369
- if (excludePrefixes.some((p) => entry.path.startsWith(p))) {
7370
- return false;
7371
- }
7372
- return true;
7373
- });
7374
- }
7375
- async function validateLiteralReuse(input) {
7376
- const config = input.config ?? literalConfigDescriptor.defaults;
7377
- const request = input.files ? {
7378
- explicit: input.files.map((f) => {
7379
- const abs = isAbsolute5(f) ? f : resolve5(input.productDir, f);
7380
- return relative5(input.productDir, abs).split(/[\\/]/g).join("/");
7381
- })
7382
- } : { walkRoot: input.productDir };
7383
- const scope = await runPipeline(
7384
- [artifactDirectoryLayer, hiddenPrefixLayer],
7385
- input.productDir,
7386
- request,
7387
- DEFAULT_SCOPE_CONFIG,
7388
- EMPTY_IGNORE_READER
7389
- );
7390
- const filtered = applyPathFilter(scope.included, input.pathConfig);
7391
- const candidateFiles = filtered.filter((entry) => isTypescriptSource(entry.path)).map((entry) => resolve5(input.productDir, entry.path));
7392
- const collectOptions = {
7393
- visitorKeys: defaultVisitorKeys,
7394
- minStringLength: config.minStringLength,
7395
- minNumberDigits: config.minNumberDigits
7396
- };
7397
- const srcOccurrences = [];
7398
- const testOccurrencesByFile = /* @__PURE__ */ new Map();
7399
- const indexedOccurrencesByFile = /* @__PURE__ */ new Map();
7400
- for (const abs of candidateFiles) {
7401
- const rel = relative5(input.productDir, abs).split(/[\\/]/g).join("/");
7402
- const content = await readSafe(abs);
7403
- if (content === null) continue;
7404
- const occurrences = collectLiterals(content, rel, collectOptions);
7405
- indexedOccurrencesByFile.set(rel, occurrences);
7406
- if (isTestFile(rel)) {
7407
- testOccurrencesByFile.set(rel, occurrences);
7408
- } else {
7409
- srcOccurrences.push(...occurrences);
7538
+ if (isFixtureDataLiteral(context, ancestors)) {
7539
+ return;
7540
+ }
7541
+ const line = node.loc?.start?.line ?? 0;
7542
+ if (node.type === LITERAL_TYPE) {
7543
+ if (typeof node.value === "string") {
7544
+ if (node.value.length >= options.minStringLength) {
7545
+ out.push({ kind: "string", value: node.value, loc: { file: context.filename, line } });
7546
+ }
7547
+ } else if (typeof node.value === "number") {
7548
+ const raw = typeof node.raw === "string" ? node.raw : String(node.value);
7549
+ if (isMeaningfulNumber(raw, options.minNumberDigits)) {
7550
+ out.push({ kind: "number", value: String(node.value), loc: { file: context.filename, line } });
7551
+ }
7410
7552
  }
7553
+ return;
7411
7554
  }
7412
- const srcIndex = buildIndex(srcOccurrences);
7413
- const findings = detectReuse({
7414
- srcIndex,
7415
- testOccurrencesByFile,
7416
- allowlist: resolveAllowlist(config)
7417
- });
7418
- return { findings, indexedOccurrencesByFile };
7419
- }
7420
- async function readSafe(path6) {
7421
- try {
7422
- return await readFile9(path6, "utf8");
7423
- } catch (err) {
7424
- if (typeof err === "object" && err !== null && "code" in err) {
7425
- const code = err.code;
7426
- if (code === "ENOENT" || code === "EISDIR") return null;
7555
+ if (node.type === TEMPLATE_ELEMENT_TYPE) {
7556
+ const value = node.value;
7557
+ const cooked = value?.cooked ?? "";
7558
+ if (cooked.length >= options.minStringLength) {
7559
+ out.push({ kind: "string", value: cooked, loc: { file: context.filename, line } });
7427
7560
  }
7428
- throw err;
7429
7561
  }
7430
7562
  }
7431
-
7432
- // src/commands/validation/literal.ts
7433
- var LITERAL_PROBLEM_KIND = {
7434
- REUSE: "reuse",
7435
- DUPE: "dupe"
7436
- };
7437
- var OUTPUT_MODE_NAME = {
7438
- TEXT: "text",
7439
- VERBOSE: "verbose",
7440
- FILES_WITH_PROBLEMS: "filesWithProblems",
7441
- LITERALS: "literals",
7442
- JSON: "json"
7443
- };
7444
- var OUTPUT_MODE_NAMES = Object.values(OUTPUT_MODE_NAME);
7445
- var LITERAL_EXIT_CODES = {
7446
- OK: 0,
7447
- FINDINGS: 1,
7448
- CONFIG_ERROR: 2
7449
- };
7450
- var TYPESCRIPT_ABSENT_MESSAGE3 = "\u23ED Skipping Literal (TypeScript not detected in project)";
7451
- var LITERAL_DISABLED_MESSAGE = "\u23ED Skipping Literal (disabled by validation.literal.enabled)";
7452
- var NO_PROBLEMS_MESSAGE = "Literal: \u2713 No problems";
7453
- function formatNoProblemsOfKind(kind) {
7454
- return `Literal: No problems of type ${kind}`;
7563
+ function isTestLikeFile(filename) {
7564
+ return filename.includes(TEST_PATH_SEGMENT) || filename.includes(WINDOWS_TEST_PATH_SEGMENT) || filename.includes(TEST_FILE_MARKER);
7455
7565
  }
7456
- async function literalCommand(options) {
7457
- const start = Date.now();
7458
- const tsDetection = detectTypeScript(options.cwd);
7459
- if (!tsDetection.present) {
7460
- return {
7461
- exitCode: LITERAL_EXIT_CODES.OK,
7462
- output: options.quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE3,
7463
- durationMs: Date.now() - start
7464
- };
7465
- }
7466
- let resolvedEnabled;
7467
- let resolvedLiteralConfig;
7468
- let resolvedPathConfig;
7469
- if (options.config !== void 0) {
7470
- resolvedEnabled = options.enabled ?? validationConfigDescriptor.defaults.literal.enabled;
7471
- resolvedLiteralConfig = options.config;
7472
- resolvedPathConfig = options.pathConfig ?? validationConfigDescriptor.defaults.paths;
7473
- } else {
7474
- const loaded = await resolveConfig(options.cwd, [validationConfigDescriptor]);
7475
- if (!loaded.ok) {
7476
- return {
7477
- exitCode: LITERAL_EXIT_CODES.CONFIG_ERROR,
7478
- output: `Literal: \u2717 config error \u2014 ${loaded.error}`,
7479
- durationMs: Date.now() - start
7480
- };
7481
- }
7482
- const validationConfig = loaded.value[validationConfigDescriptor.section];
7483
- resolvedEnabled = validationConfig.literal.enabled;
7484
- resolvedLiteralConfig = validationConfig.literal.values;
7485
- resolvedPathConfig = validationPathFilterForTool(
7486
- validationConfig.paths,
7487
- VALIDATION_PATH_TOOL_SUBSECTIONS.LITERAL
7488
- );
7566
+ function isFixtureDataLiteral(context, ancestors) {
7567
+ if (!context.isTestFixtureFile) {
7568
+ return false;
7489
7569
  }
7490
- if (!resolvedEnabled) {
7491
- return {
7492
- exitCode: LITERAL_EXIT_CODES.OK,
7493
- output: options.quiet ? "" : LITERAL_DISABLED_MESSAGE,
7494
- durationMs: Date.now() - start
7495
- };
7570
+ return isInsideFixtureWriterArgument(ancestors) || isInsideFixtureDataVariable(ancestors);
7571
+ }
7572
+ function isInsideFixtureWriterArgument(ancestors) {
7573
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
7574
+ const ancestor = ancestors[index];
7575
+ if (ancestor.node.type !== CALL_EXPRESSION_TYPE) {
7576
+ continue;
7577
+ }
7578
+ const callName = getCallName(ancestor.node);
7579
+ if (callName === void 0 || !isFixtureWriterCall(callName)) {
7580
+ continue;
7581
+ }
7582
+ if (hasNestedFunctionBetween(ancestors, index)) {
7583
+ continue;
7584
+ }
7585
+ return true;
7496
7586
  }
7497
- const result = await validateLiteralReuse({
7498
- productDir: options.cwd,
7499
- files: options.files,
7500
- config: resolvedLiteralConfig,
7501
- pathConfig: resolvedPathConfig
7502
- });
7503
- const filteredFindings = filterLiteralFindings(result.findings, options.kind);
7504
- const totalProblems = countLiteralProblems(filteredFindings);
7505
- const exitCode = totalProblems === 0 ? LITERAL_EXIT_CODES.OK : LITERAL_EXIT_CODES.FINDINGS;
7506
- const output = options.json ? JSON.stringify(filteredFindings) : options.quiet ? "" : formatLiteralCommandOutput(filteredFindings, options);
7507
- return { exitCode, output, durationMs: Date.now() - start };
7587
+ return false;
7508
7588
  }
7509
- function filterLiteralFindings(findings, kind) {
7510
- return {
7511
- srcReuse: kind === LITERAL_PROBLEM_KIND.DUPE ? [] : sortReuseFindings(findings.srcReuse),
7512
- testDupe: kind === LITERAL_PROBLEM_KIND.REUSE ? [] : sortDupeFindings(findings.testDupe)
7513
- };
7589
+ function isFixtureWriterCall(callName) {
7590
+ return FIXTURE_WRITER_CALLS.has(callName);
7514
7591
  }
7515
- function countLiteralProblems(findings) {
7516
- return findings.srcReuse.length + findings.testDupe.length;
7592
+ function isInsideFixtureDataVariable(ancestors) {
7593
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
7594
+ const ancestor = ancestors[index];
7595
+ if (ancestor.node.type !== VARIABLE_DECLARATOR_TYPE) {
7596
+ continue;
7597
+ }
7598
+ if (hasNestedFunctionBetween(ancestors, index)) {
7599
+ continue;
7600
+ }
7601
+ const variableName = getFixtureDataDeclaratorName(ancestor.node);
7602
+ if (variableName !== void 0 && isFixtureDataVariableName(variableName)) {
7603
+ return true;
7604
+ }
7605
+ }
7606
+ return false;
7517
7607
  }
7518
- function formatDefaultLiteralProblems(findings) {
7519
- return toLiteralProblems(findings).map(
7520
- (problem) => `[${problem.problemKind}] ${formatLiteralValue(problem.literalKind, problem.value)} ${formatLoc(problem.test)}`
7521
- ).join("\n");
7608
+ function hasNestedFunctionBetween(ancestors, ancestorIndex) {
7609
+ for (let index = ancestorIndex + 1; index < ancestors.length; index += 1) {
7610
+ if (FUNCTION_NODE_TYPES.has(ancestors[index].node.type)) {
7611
+ return true;
7612
+ }
7613
+ }
7614
+ return false;
7522
7615
  }
7523
- function formatVerboseLiteralProblems(findings) {
7524
- const lines = [
7525
- `Literal: ${countLiteralProblems(findings)} problems (reuse: ${findings.srcReuse.length}, dupe: ${findings.testDupe.length})`
7526
- ];
7527
- appendVerboseSection(
7528
- lines,
7529
- "REUSE",
7530
- findings.srcReuse.map((finding) => ({
7531
- problemKind: LITERAL_PROBLEM_KIND.REUSE,
7532
- literalKind: finding.kind,
7533
- value: finding.value,
7534
- test: finding.test,
7535
- related: finding.src
7536
- }))
7537
- );
7538
- appendVerboseSection(
7539
- lines,
7540
- "DUPE",
7541
- findings.testDupe.map((finding) => ({
7542
- problemKind: LITERAL_PROBLEM_KIND.DUPE,
7543
- literalKind: finding.kind,
7544
- value: finding.value,
7545
- test: finding.test,
7546
- related: finding.otherTests
7547
- }))
7548
- );
7549
- return lines.join("\n");
7616
+ function getFixtureDataDeclaratorName(node) {
7617
+ const bindingName = getIdentifierName(node.id);
7618
+ if (bindingName !== void 0) {
7619
+ return bindingName;
7620
+ }
7621
+ return getIdentifierName(node.init);
7550
7622
  }
7551
- function formatFilesWithProblems(findings) {
7552
- return [...new Set(toLiteralProblems(findings).map((problem) => problem.test.file))].sort().join("\n");
7623
+ function isFixtureDataVariableName(variableName) {
7624
+ const segments = splitIdentifierName(variableName);
7625
+ if (segments.length === 0) {
7626
+ return false;
7627
+ }
7628
+ if (segments.some((segment) => FIXTURE_DATA_DIRECT_SEGMENTS.has(segment))) {
7629
+ return true;
7630
+ }
7631
+ if (!segments.some((segment) => FIXTURE_DATA_ROLE_SEGMENTS.has(segment))) {
7632
+ return false;
7633
+ }
7634
+ if (segments.every((segment) => FIXTURE_DATA_ROLE_SEGMENTS.has(segment))) {
7635
+ return true;
7636
+ }
7637
+ const finalSegment = segments[segments.length - 1];
7638
+ return finalSegment !== void 0 && FIXTURE_DATA_CONTEXT_SEGMENTS.has(finalSegment);
7553
7639
  }
7554
- function formatLiteralValues(findings) {
7555
- const values = /* @__PURE__ */ new Map();
7556
- for (const problem of toLiteralProblems(findings)) {
7557
- values.set(`${problem.literalKind}\0${problem.value}`, {
7558
- kind: problem.literalKind,
7559
- value: problem.value
7560
- });
7640
+ function splitIdentifierName(variableName) {
7641
+ return variableName.split("_").flatMap((part) => part.match(IDENTIFIER_SEGMENT_PATTERN) ?? []).map((segment) => segment.toLowerCase());
7642
+ }
7643
+ function getCallName(node) {
7644
+ const callee = node.callee;
7645
+ if (!isNode(callee)) {
7646
+ return void 0;
7561
7647
  }
7562
- return [...values.values()].sort((left, right) => left.value.localeCompare(right.value) || left.kind.localeCompare(right.kind)).map((entry) => formatLiteralValue(entry.kind, entry.value)).join("\n");
7648
+ if (callee.type === IDENTIFIER_TYPE) {
7649
+ return getIdentifierName(callee);
7650
+ }
7651
+ if (callee.type !== MEMBER_EXPRESSION_TYPE) {
7652
+ return void 0;
7653
+ }
7654
+ const property = callee.property;
7655
+ if (isNode(property)) {
7656
+ return property.type === IDENTIFIER_TYPE ? getIdentifierName(property) : getLiteralString(property);
7657
+ }
7658
+ return void 0;
7563
7659
  }
7564
- function formatLiteralCommandOutput(findings, options) {
7565
- const totalProblems = countLiteralProblems(findings);
7566
- if (totalProblems === 0 && options.kind !== void 0) {
7567
- return formatNoProblemsOfKind(options.kind);
7660
+ function getIdentifierName(value) {
7661
+ if (!isNode(value) || value.type !== IDENTIFIER_TYPE) {
7662
+ return void 0;
7568
7663
  }
7569
- if (totalProblems === 0) {
7570
- return options.filesWithProblems || options.literals || options.verbose ? "" : NO_PROBLEMS_MESSAGE;
7664
+ return typeof value.name === "string" ? value.name : void 0;
7665
+ }
7666
+ function getLiteralString(value) {
7667
+ if (!isNode(value) || value.type !== LITERAL_TYPE) {
7668
+ return void 0;
7571
7669
  }
7572
- if (options.filesWithProblems) return formatFilesWithProblems(findings);
7573
- if (options.literals) return formatLiteralValues(findings);
7574
- if (options.verbose) return formatVerboseLiteralProblems(findings);
7575
- return formatDefaultLiteralProblems(findings);
7670
+ return typeof value.value === "string" ? value.value : void 0;
7576
7671
  }
7577
- function toLiteralProblems(findings) {
7578
- return [
7579
- ...sortReuseFindings(findings.srcReuse).map((finding) => ({
7580
- problemKind: LITERAL_PROBLEM_KIND.REUSE,
7581
- literalKind: finding.kind,
7582
- value: finding.value,
7583
- test: finding.test,
7584
- related: finding.src
7585
- })),
7586
- ...sortDupeFindings(findings.testDupe).map((finding) => ({
7587
- problemKind: LITERAL_PROBLEM_KIND.DUPE,
7588
- literalKind: finding.kind,
7589
- value: finding.value,
7590
- test: finding.test,
7591
- related: finding.otherTests
7592
- }))
7593
- ];
7672
+ function isMeaningfulNumber(raw, minDigits) {
7673
+ const digits = raw.replace(/[^0-9]/g, "");
7674
+ return digits.length >= minDigits;
7594
7675
  }
7595
- function appendVerboseSection(lines, heading, problems) {
7596
- const sortedProblems = [...problems].sort(compareLiteralProblems);
7597
- if (sortedProblems.length === 0) return;
7598
- lines.push(heading);
7599
- let currentFile;
7600
- for (const problem of sortedProblems) {
7601
- if (problem.test.file !== currentFile) {
7602
- lines.push(problem.test.file);
7603
- currentFile = problem.test.file;
7676
+ function buildIndex(occurrences) {
7677
+ const map = /* @__PURE__ */ new Map();
7678
+ for (const occ of occurrences) {
7679
+ const key = makeKey(occ.kind, occ.value);
7680
+ const existing = map.get(key);
7681
+ if (existing) {
7682
+ existing.push(occ.loc);
7683
+ } else {
7684
+ map.set(key, [occ.loc]);
7604
7685
  }
7605
- lines.push(
7606
- ` line ${problem.test.line}: ${formatLiteralValue(problem.literalKind, problem.value)} also in ${problem.related.map(formatLoc).join(", ")}`
7607
- );
7608
7686
  }
7687
+ return map;
7609
7688
  }
7610
- function sortReuseFindings(findings) {
7611
- return [...findings].sort(compareFindings);
7689
+ function makeKey(kind, value) {
7690
+ return `${kind}\0${value}`;
7612
7691
  }
7613
- function sortDupeFindings(findings) {
7614
- return [...findings].sort(compareFindings);
7692
+ function splitKey(key) {
7693
+ const idx = key.indexOf("\0");
7694
+ return { kind: key.slice(0, idx), value: key.slice(idx + 1) };
7615
7695
  }
7616
- function compareFindings(left, right) {
7617
- return left.test.file.localeCompare(right.test.file) || left.test.line - right.test.line || left.kind.localeCompare(right.kind) || left.value.localeCompare(right.value);
7696
+ function detectReuse(input) {
7697
+ const srcReuse = [];
7698
+ const testDupe = [];
7699
+ const testIndex = /* @__PURE__ */ new Map();
7700
+ for (const [file, occurrences] of input.testOccurrencesByFile) {
7701
+ for (const occ of occurrences) {
7702
+ if (input.allowlist.has(occ.value)) continue;
7703
+ const key = makeKey(occ.kind, occ.value);
7704
+ let byFile = testIndex.get(key);
7705
+ if (!byFile) {
7706
+ byFile = /* @__PURE__ */ new Map();
7707
+ testIndex.set(key, byFile);
7708
+ }
7709
+ const locsInFile = byFile.get(file);
7710
+ if (locsInFile) locsInFile.push(occ.loc);
7711
+ else byFile.set(file, [occ.loc]);
7712
+ }
7713
+ }
7714
+ for (const [key, byFile] of testIndex) {
7715
+ const { kind, value } = splitKey(key);
7716
+ const srcLocs = input.srcIndex.get(key);
7717
+ const allTestLocs = [];
7718
+ for (const locs of byFile.values()) allTestLocs.push(...locs);
7719
+ if (srcLocs && srcLocs.length > 0) {
7720
+ for (const testLoc of allTestLocs) {
7721
+ srcReuse.push({
7722
+ test: testLoc,
7723
+ kind,
7724
+ value,
7725
+ src: srcLocs,
7726
+ remediation: REMEDIATION.IMPORT_FROM_SOURCE
7727
+ });
7728
+ }
7729
+ } else if (byFile.size >= 2) {
7730
+ for (let i = 0; i < allTestLocs.length; i += 1) {
7731
+ const otherTests = [...allTestLocs.slice(0, i), ...allTestLocs.slice(i + 1)];
7732
+ testDupe.push({
7733
+ test: allTestLocs[i],
7734
+ kind,
7735
+ value,
7736
+ otherTests,
7737
+ remediation: REMEDIATION.REFACTOR_TO_SOURCE_OR_GENERATOR
7738
+ });
7739
+ }
7740
+ }
7741
+ }
7742
+ return { srcReuse, testDupe };
7618
7743
  }
7619
- function compareLiteralProblems(left, right) {
7620
- return left.test.file.localeCompare(right.test.file) || left.test.line - right.test.line || left.literalKind.localeCompare(right.literalKind) || left.value.localeCompare(right.value);
7744
+
7745
+ // src/validation/literal/walker.ts
7746
+ var TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx"]);
7747
+ var DECLARATION_SUFFIX = ".d.ts";
7748
+ function isTypescriptSource(path6) {
7749
+ if (path6.endsWith(DECLARATION_SUFFIX)) return false;
7750
+ const ext = extensionOf(path6);
7751
+ return TYPESCRIPT_EXTENSIONS.has(ext);
7621
7752
  }
7622
- function formatLiteralValue(kind, value) {
7623
- return kind === "string" ? `"${value}"` : value;
7753
+ function extensionOf(name) {
7754
+ const idx = name.lastIndexOf(".");
7755
+ return idx === -1 ? "" : name.slice(idx);
7624
7756
  }
7625
- function formatLoc(loc) {
7626
- return `${loc.file}:${loc.line}`;
7757
+ function isTestFile(relPath) {
7758
+ return /\.test\.tsx?$/.test(relPath);
7627
7759
  }
7628
7760
 
7629
- // src/commands/validation/markdown.ts
7630
- import { relative as relative6 } from "path";
7631
-
7632
- // src/validation/steps/markdown.ts
7633
- import { existsSync as existsSync6, statSync as statSync2 } from "fs";
7634
- import { basename, dirname as dirname3, join as join17, relative as pathRelative } from "path";
7635
- import { main as markdownlintMain } from "markdownlint-cli2";
7636
- import relativeLinksRule from "markdownlint-rule-relative-links";
7637
- var MARKDOWN_DEFAULT_DIRECTORY_NAMES = ["spx", "docs"];
7638
- var MARKDOWN_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown"]);
7639
- var MARKDOWN_DIRECTORY_GLOB = "**/*.md";
7640
- var ENABLED_RULES = {
7641
- MD001: true,
7642
- MD003: true,
7643
- MD009: true,
7644
- MD010: true,
7645
- MD025: true,
7646
- MD047: true
7647
- };
7648
- var MD024_DISABLED_DIRECTORIES = ["docs"];
7649
- var MARKDOWN_CUSTOM_RULE_NAMES = relativeLinksRule.names;
7650
- var MARKDOWN_VALIDATION_TARGET_KIND = {
7651
- DIRECTORY: "directory",
7652
- FILE: "file"
7653
- };
7654
- var MARKDOWN_VALIDATION_TARGET_DIAGNOSTICS = {
7655
- MISSING_OR_UNRELATED_SCOPE: "not an existing directory or markdown file"
7656
- };
7657
- var ERROR_LINE_PATTERN = /^(.+?):(\d+)(?::\d+)?\s+(.+)$/;
7658
- var defaultMarkdownValidationTargetDeps = {
7659
- statSync: statSync2
7761
+ // src/validation/literal/index.ts
7762
+ var PATH_PREFIX_SEPARATOR2 = "/";
7763
+ var DEFAULT_LITERAL_COLLECT_OPTIONS = {
7764
+ visitorKeys: defaultVisitorKeys,
7765
+ minStringLength: literalConfigDescriptor.defaults.minStringLength,
7766
+ minNumberDigits: literalConfigDescriptor.defaults.minNumberDigits
7660
7767
  };
7661
- function buildMarkdownlintConfig(directoryName) {
7662
- const md024Disabled = MD024_DISABLED_DIRECTORIES.includes(
7663
- directoryName
7664
- );
7665
- return {
7666
- default: false,
7667
- ...ENABLED_RULES,
7668
- MD024: md024Disabled ? false : { siblings_only: true },
7669
- customRules: [relativeLinksRule]
7670
- };
7671
- }
7672
- function getDefaultDirectories(projectRoot) {
7673
- return MARKDOWN_DEFAULT_DIRECTORY_NAMES.map((name) => join17(projectRoot, name)).filter((dir) => existsSync6(dir));
7768
+ function normalizePathPrefix2(prefix) {
7769
+ const posix = prefix.split(/[\\/]/g).join(PATH_PREFIX_SEPARATOR2);
7770
+ return posix.endsWith(PATH_PREFIX_SEPARATOR2) ? posix : `${posix}${PATH_PREFIX_SEPARATOR2}`;
7674
7771
  }
7675
- function resolveMarkdownValidationTarget(path6, deps = defaultMarkdownValidationTargetDeps) {
7676
- if (isExistingDirectory(path6, deps)) {
7677
- return { target: { kind: MARKDOWN_VALIDATION_TARGET_KIND.DIRECTORY, path: path6 } };
7678
- }
7679
- if (hasMarkdownExtension(path6) && isExistingFile(path6, deps)) {
7680
- return { target: { kind: MARKDOWN_VALIDATION_TARGET_KIND.FILE, path: path6 } };
7772
+ function applyPathFilter(entries, pathConfig) {
7773
+ if (pathConfig === void 0) {
7774
+ return entries;
7681
7775
  }
7682
- return {
7683
- skipped: {
7684
- path: path6,
7685
- reason: MARKDOWN_VALIDATION_TARGET_DIAGNOSTICS.MISSING_OR_UNRELATED_SCOPE
7776
+ const includePrefixes = (pathConfig.include ?? []).map(normalizePathPrefix2);
7777
+ const excludePrefixes = (pathConfig.exclude ?? []).map(normalizePathPrefix2);
7778
+ return entries.filter((entry) => {
7779
+ if (includePrefixes.length > 0 && !includePrefixes.some((p) => entry.path.startsWith(p))) {
7780
+ return false;
7686
7781
  }
7687
- };
7688
- }
7689
- function getExcludeGlobs(projectRoot) {
7690
- if (projectRoot === void 0) return [];
7691
- const reader = createIgnoreSourceReader(projectRoot, {
7692
- ignoreSourceFilename: IGNORE_SOURCE_FILENAME_DEFAULT,
7693
- specTreeRootSegment: SPEC_TREE_CONFIG.ROOT_DIRECTORY
7782
+ if (excludePrefixes.some((p) => entry.path.startsWith(p))) {
7783
+ return false;
7784
+ }
7785
+ return true;
7694
7786
  });
7695
- return reader.entries().map((entry) => `${entry.segment}/**`);
7696
7787
  }
7697
- var DATA_URI_PATTERN = /\bdata:/;
7698
- function parseErrorLine(line) {
7699
- if (DATA_URI_PATTERN.test(line)) {
7700
- return null;
7701
- }
7702
- const match = ERROR_LINE_PATTERN.exec(line);
7703
- if (!match) {
7704
- return null;
7705
- }
7706
- const [, file, lineStr, detail] = match;
7707
- return {
7708
- file,
7709
- line: parseInt(lineStr, 10),
7710
- detail
7788
+ async function validateLiteralReuse(input) {
7789
+ const config = input.config ?? literalConfigDescriptor.defaults;
7790
+ const request = input.files ? {
7791
+ explicit: input.files.map((f) => {
7792
+ const abs = isAbsolute5(f) ? f : resolve5(input.productDir, f);
7793
+ return relative6(input.productDir, abs).split(/[\\/]/g).join("/");
7794
+ })
7795
+ } : { walkRoot: input.productDir };
7796
+ const scope = await runPipeline(
7797
+ [artifactDirectoryLayer, hiddenPrefixLayer],
7798
+ input.productDir,
7799
+ request,
7800
+ DEFAULT_SCOPE_CONFIG,
7801
+ EMPTY_IGNORE_READER
7802
+ );
7803
+ const filtered = applyPathFilter(scope.included, input.pathConfig);
7804
+ const candidateFiles = filtered.filter((entry) => isTypescriptSource(entry.path)).map((entry) => resolve5(input.productDir, entry.path));
7805
+ const collectOptions = {
7806
+ visitorKeys: defaultVisitorKeys,
7807
+ minStringLength: config.minStringLength,
7808
+ minNumberDigits: config.minNumberDigits
7711
7809
  };
7810
+ const srcOccurrences = [];
7811
+ const testOccurrencesByFile = /* @__PURE__ */ new Map();
7812
+ const indexedOccurrencesByFile = /* @__PURE__ */ new Map();
7813
+ for (const abs of candidateFiles) {
7814
+ const rel = relative6(input.productDir, abs).split(/[\\/]/g).join("/");
7815
+ const content = await readSafe(abs);
7816
+ if (content === null) continue;
7817
+ const occurrences = collectLiterals(content, rel, collectOptions);
7818
+ indexedOccurrencesByFile.set(rel, occurrences);
7819
+ if (isTestFile(rel)) {
7820
+ testOccurrencesByFile.set(rel, occurrences);
7821
+ } else {
7822
+ srcOccurrences.push(...occurrences);
7823
+ }
7824
+ }
7825
+ const srcIndex = buildIndex(srcOccurrences);
7826
+ const findings = detectReuse({
7827
+ srcIndex,
7828
+ testOccurrencesByFile,
7829
+ allowlist: resolveAllowlist(config)
7830
+ });
7831
+ return { findings, indexedOccurrencesByFile };
7712
7832
  }
7713
- async function validateMarkdown(options) {
7714
- const { targets, projectRoot } = options;
7715
- const errors = [];
7716
- const excludeGlobs = getExcludeGlobs(projectRoot);
7717
- for (const target of targets) {
7718
- const directory = targetDirectory(target);
7719
- const dirName = markdownlintConfigDirectoryName(directory, projectRoot);
7720
- const config = buildMarkdownlintConfig(dirName);
7721
- const dirErrors = await validateTarget(target, config, projectRoot, excludeGlobs);
7722
- errors.push(...dirErrors);
7833
+ async function readSafe(path6) {
7834
+ try {
7835
+ return await readFile8(path6, "utf8");
7836
+ } catch (err) {
7837
+ if (typeof err === "object" && err !== null && "code" in err) {
7838
+ const code = err.code;
7839
+ if (code === "ENOENT" || code === "EISDIR") return null;
7840
+ }
7841
+ throw err;
7723
7842
  }
7724
- return {
7725
- success: errors.length === 0,
7726
- errors
7727
- };
7728
7843
  }
7729
- async function validateTarget(target, config, projectRoot, ignoreGlobs = []) {
7730
- const errors = [];
7731
- const directory = targetDirectory(target);
7732
- const argv = target.kind === MARKDOWN_VALIDATION_TARGET_KIND.FILE ? [basename(target.path)] : [MARKDOWN_DIRECTORY_GLOB];
7733
- const { customRules, ...markdownlintConfig } = config;
7734
- const optionsOverride = {
7735
- config: {
7736
- ...markdownlintConfig,
7737
- "relative-links": projectRoot ? { root_path: projectRoot } : true
7738
- },
7739
- customRules,
7740
- noProgress: true,
7741
- noBanner: true,
7742
- ...ignoreGlobs.length > 0 ? { ignores: ignoreGlobs } : {}
7743
- };
7744
- await markdownlintMain({
7745
- directory,
7746
- argv,
7747
- optionsOverride,
7748
- noImport: true,
7749
- logMessage: () => {
7750
- },
7751
- logError: (message) => {
7752
- const parsed = parseErrorLine(message);
7753
- if (parsed) {
7754
- errors.push({
7755
- ...parsed,
7756
- file: join17(directory, parsed.file)
7757
- });
7758
- }
7844
+
7845
+ // src/commands/validation/literal.ts
7846
+ var LITERAL_PROBLEM_KIND = {
7847
+ REUSE: "reuse",
7848
+ DUPE: "dupe"
7849
+ };
7850
+ var OUTPUT_MODE_NAME = {
7851
+ TEXT: "text",
7852
+ VERBOSE: "verbose",
7853
+ FILES_WITH_PROBLEMS: "filesWithProblems",
7854
+ LITERALS: "literals",
7855
+ JSON: "json"
7856
+ };
7857
+ var OUTPUT_MODE_NAMES = Object.values(OUTPUT_MODE_NAME);
7858
+ var LITERAL_EXIT_CODES = {
7859
+ OK: 0,
7860
+ FINDINGS: 1,
7861
+ CONFIG_ERROR: 2
7862
+ };
7863
+ var TYPESCRIPT_ABSENT_MESSAGE3 = "\u23ED Skipping Literal (TypeScript not detected in project)";
7864
+ var LITERAL_DISABLED_MESSAGE = "\u23ED Skipping Literal (disabled by validation.literal.enabled)";
7865
+ var NO_PROBLEMS_MESSAGE = "Literal: \u2713 No problems";
7866
+ function formatNoProblemsOfKind(kind) {
7867
+ return `Literal: No problems of type ${kind}`;
7868
+ }
7869
+ async function literalCommand(options) {
7870
+ const start = Date.now();
7871
+ const tsDetection = detectTypeScript(options.cwd);
7872
+ if (!tsDetection.present) {
7873
+ return {
7874
+ exitCode: LITERAL_EXIT_CODES.OK,
7875
+ output: options.quiet ? "" : TYPESCRIPT_ABSENT_MESSAGE3,
7876
+ durationMs: Date.now() - start
7877
+ };
7878
+ }
7879
+ let resolvedEnabled;
7880
+ let resolvedLiteralConfig;
7881
+ let resolvedPathConfig;
7882
+ if (options.config !== void 0) {
7883
+ resolvedEnabled = options.enabled ?? validationConfigDescriptor.defaults.literal.enabled;
7884
+ resolvedLiteralConfig = options.config;
7885
+ resolvedPathConfig = options.pathConfig ?? validationConfigDescriptor.defaults.paths;
7886
+ } else {
7887
+ const loaded = await resolveConfig(options.cwd, [validationConfigDescriptor]);
7888
+ if (!loaded.ok) {
7889
+ return {
7890
+ exitCode: LITERAL_EXIT_CODES.CONFIG_ERROR,
7891
+ output: `Literal: \u2717 config error \u2014 ${loaded.error}`,
7892
+ durationMs: Date.now() - start
7893
+ };
7759
7894
  }
7895
+ const validationConfig = loaded.value[validationConfigDescriptor.section];
7896
+ resolvedEnabled = validationConfig.literal.enabled;
7897
+ resolvedLiteralConfig = validationConfig.literal.values;
7898
+ resolvedPathConfig = validationPathFilterForTool(
7899
+ validationConfig.paths,
7900
+ VALIDATION_PATH_TOOL_SUBSECTIONS.LITERAL
7901
+ );
7902
+ }
7903
+ if (!resolvedEnabled) {
7904
+ return {
7905
+ exitCode: LITERAL_EXIT_CODES.OK,
7906
+ output: options.quiet ? "" : LITERAL_DISABLED_MESSAGE,
7907
+ durationMs: Date.now() - start
7908
+ };
7909
+ }
7910
+ const result = await validateLiteralReuse({
7911
+ productDir: options.cwd,
7912
+ files: options.files,
7913
+ config: resolvedLiteralConfig,
7914
+ pathConfig: resolvedPathConfig
7760
7915
  });
7761
- return errors;
7916
+ const filteredFindings = filterLiteralFindings(result.findings, options.kind);
7917
+ const totalProblems = countLiteralProblems(filteredFindings);
7918
+ const exitCode = totalProblems === 0 ? LITERAL_EXIT_CODES.OK : LITERAL_EXIT_CODES.FINDINGS;
7919
+ const output = options.json ? JSON.stringify(filteredFindings) : options.quiet ? "" : formatLiteralCommandOutput(filteredFindings, options);
7920
+ return { exitCode, output, durationMs: Date.now() - start };
7762
7921
  }
7763
- function targetDirectory(target) {
7764
- return target.kind === MARKDOWN_VALIDATION_TARGET_KIND.FILE ? dirname3(target.path) : target.path;
7922
+ function filterLiteralFindings(findings, kind) {
7923
+ return {
7924
+ srcReuse: kind === LITERAL_PROBLEM_KIND.DUPE ? [] : sortReuseFindings(findings.srcReuse),
7925
+ testDupe: kind === LITERAL_PROBLEM_KIND.REUSE ? [] : sortDupeFindings(findings.testDupe)
7926
+ };
7765
7927
  }
7766
- function hasMarkdownExtension(path6) {
7767
- const lastDot = path6.lastIndexOf(".");
7768
- if (lastDot < 0) return false;
7769
- return MARKDOWN_FILE_EXTENSIONS.has(path6.slice(lastDot).toLowerCase());
7928
+ function countLiteralProblems(findings) {
7929
+ return findings.srcReuse.length + findings.testDupe.length;
7770
7930
  }
7771
- function isExistingDirectory(path6, deps) {
7772
- try {
7773
- return deps.statSync(path6).isDirectory();
7774
- } catch {
7775
- return false;
7776
- }
7931
+ function formatDefaultLiteralProblems(findings) {
7932
+ return toLiteralProblems(findings).map(
7933
+ (problem) => `[${problem.problemKind}] ${formatLiteralValue(problem.literalKind, problem.value)} ${formatLoc(problem.test)}`
7934
+ ).join("\n");
7777
7935
  }
7778
- function isExistingFile(path6, deps) {
7779
- try {
7780
- return deps.statSync(path6).isFile();
7781
- } catch {
7782
- return false;
7783
- }
7936
+ function formatVerboseLiteralProblems(findings) {
7937
+ const lines = [
7938
+ `Literal: ${countLiteralProblems(findings)} problems (reuse: ${findings.srcReuse.length}, dupe: ${findings.testDupe.length})`
7939
+ ];
7940
+ appendVerboseSection(
7941
+ lines,
7942
+ "REUSE",
7943
+ findings.srcReuse.map((finding) => ({
7944
+ problemKind: LITERAL_PROBLEM_KIND.REUSE,
7945
+ literalKind: finding.kind,
7946
+ value: finding.value,
7947
+ test: finding.test,
7948
+ related: finding.src
7949
+ }))
7950
+ );
7951
+ appendVerboseSection(
7952
+ lines,
7953
+ "DUPE",
7954
+ findings.testDupe.map((finding) => ({
7955
+ problemKind: LITERAL_PROBLEM_KIND.DUPE,
7956
+ literalKind: finding.kind,
7957
+ value: finding.value,
7958
+ test: finding.test,
7959
+ related: finding.otherTests
7960
+ }))
7961
+ );
7962
+ return lines.join("\n");
7784
7963
  }
7785
- function markdownlintConfigDirectoryName(directory, projectRoot) {
7786
- if (projectRoot !== void 0) {
7787
- const [rootSegment] = pathRelative(projectRoot, directory).split(/[\\/]/);
7788
- if (MD024_DISABLED_DIRECTORIES.includes(rootSegment)) {
7789
- return rootSegment;
7790
- }
7964
+ function formatFilesWithProblems(findings) {
7965
+ return [...new Set(toLiteralProblems(findings).map((problem) => problem.test.file))].sort().join("\n");
7966
+ }
7967
+ function formatLiteralValues(findings) {
7968
+ const values = /* @__PURE__ */ new Map();
7969
+ for (const problem of toLiteralProblems(findings)) {
7970
+ values.set(`${problem.literalKind}\0${problem.value}`, {
7971
+ kind: problem.literalKind,
7972
+ value: problem.value
7973
+ });
7791
7974
  }
7792
- return basename(directory);
7975
+ return [...values.values()].sort((left, right) => left.value.localeCompare(right.value) || left.kind.localeCompare(right.kind)).map((entry) => formatLiteralValue(entry.kind, entry.value)).join("\n");
7793
7976
  }
7794
-
7795
- // src/commands/validation/markdown.ts
7796
- var MARKDOWN_COMMAND_OUTPUT = {
7797
- ERROR_SUMMARY_SUFFIX: VALIDATION_COMMAND_OUTPUT.MARKDOWN_ERROR_SUMMARY_SUFFIX,
7798
- NO_ISSUES: VALIDATION_COMMAND_OUTPUT.MARKDOWN_NO_ISSUES,
7799
- SKIPPED_FILE_SCOPE_PREFIX: "Markdown skipped file scope"
7800
- };
7801
- var MARKDOWN_CONFIG_ERROR_MESSAGE = `${VALIDATION_STAGE_DISPLAY_NAMES.MARKDOWN}: \u2717 config error`;
7802
- async function markdownCommand(options) {
7803
- const { cwd, files, quiet } = options;
7804
- const startTime = Date.now();
7805
- const loaded = await resolveConfig(cwd, [validationConfigDescriptor]);
7806
- if (!loaded.ok) {
7807
- return {
7808
- exitCode: 1,
7809
- output: `${MARKDOWN_CONFIG_ERROR_MESSAGE} \u2014 ${loaded.error}`,
7810
- durationMs: Date.now() - startTime
7811
- };
7977
+ function formatLiteralCommandOutput(findings, options) {
7978
+ const totalProblems = countLiteralProblems(findings);
7979
+ if (totalProblems === 0 && options.kind !== void 0) {
7980
+ return formatNoProblemsOfKind(options.kind);
7812
7981
  }
7813
- const validationConfig = loaded.value[validationConfigDescriptor.section];
7814
- const pathFilter = validationPathFilterForTool(
7815
- validationConfig.paths,
7816
- VALIDATION_PATH_TOOL_SUBSECTIONS.MARKDOWN
7817
- );
7818
- const targetResolutions = files && files.length > 0 ? files.map((filePath) => resolveMarkdownValidationTarget(filePath)) : void 0;
7819
- const unfilteredTargets = targetResolutions !== void 0 ? targetResolutions.map((resolution) => resolution.target).filter((target) => target !== void 0) : getDefaultDirectories(cwd).map((path6) => ({
7820
- kind: MARKDOWN_VALIDATION_TARGET_KIND.DIRECTORY,
7821
- path: path6
7822
- }));
7823
- const targets = unfilteredTargets.filter(
7824
- (target) => pathPassesValidationFilter(relative6(cwd, target.path), pathFilter)
7825
- );
7826
- const skippedTargets = targetResolutions === void 0 ? [] : targetResolutions.map((resolution) => resolution.skipped).filter((skipped) => skipped !== void 0);
7827
- const skippedOutput = quiet ? [] : skippedTargets.map(formatSkippedFileScope);
7828
- if (targets.length === 0) {
7829
- const reason = files && files.length > 0 ? VALIDATION_SKIP_LABELS.MARKDOWN_NO_SCOPE_REASON : VALIDATION_SKIP_LABELS.MARKDOWN_NO_DEFAULT_DIRECTORIES_REASON;
7830
- const output = quiet ? "" : [
7831
- ...skippedOutput,
7832
- `${VALIDATION_STAGE_DISPLAY_NAMES.MARKDOWN}: skipped (${reason})`
7833
- ].join("\n");
7834
- return { exitCode: 0, output, durationMs: Date.now() - startTime };
7982
+ if (totalProblems === 0) {
7983
+ return options.filesWithProblems || options.literals || options.verbose ? "" : NO_PROBLEMS_MESSAGE;
7835
7984
  }
7836
- const result = await validateMarkdown({
7837
- targets,
7838
- projectRoot: cwd
7839
- });
7840
- const durationMs = Date.now() - startTime;
7841
- if (result.success) {
7842
- const output = quiet ? "" : [...skippedOutput, MARKDOWN_COMMAND_OUTPUT.NO_ISSUES].join("\n");
7843
- return { exitCode: 0, output, durationMs };
7844
- } else {
7845
- const errorLines = result.errors.map(
7846
- (error) => ` ${error.file}:${error.line} ${error.detail}`
7985
+ if (options.filesWithProblems) return formatFilesWithProblems(findings);
7986
+ if (options.literals) return formatLiteralValues(findings);
7987
+ if (options.verbose) return formatVerboseLiteralProblems(findings);
7988
+ return formatDefaultLiteralProblems(findings);
7989
+ }
7990
+ function toLiteralProblems(findings) {
7991
+ return [
7992
+ ...sortReuseFindings(findings.srcReuse).map((finding) => ({
7993
+ problemKind: LITERAL_PROBLEM_KIND.REUSE,
7994
+ literalKind: finding.kind,
7995
+ value: finding.value,
7996
+ test: finding.test,
7997
+ related: finding.src
7998
+ })),
7999
+ ...sortDupeFindings(findings.testDupe).map((finding) => ({
8000
+ problemKind: LITERAL_PROBLEM_KIND.DUPE,
8001
+ literalKind: finding.kind,
8002
+ value: finding.value,
8003
+ test: finding.test,
8004
+ related: finding.otherTests
8005
+ }))
8006
+ ];
8007
+ }
8008
+ function appendVerboseSection(lines, heading, problems) {
8009
+ const sortedProblems = [...problems].sort(compareLiteralProblems);
8010
+ if (sortedProblems.length === 0) return;
8011
+ lines.push(heading);
8012
+ let currentFile;
8013
+ for (const problem of sortedProblems) {
8014
+ if (problem.test.file !== currentFile) {
8015
+ lines.push(problem.test.file);
8016
+ currentFile = problem.test.file;
8017
+ }
8018
+ lines.push(
8019
+ ` line ${problem.test.line}: ${formatLiteralValue(problem.literalKind, problem.value)} also in ${problem.related.map(formatLoc).join(", ")}`
7847
8020
  );
7848
- const output = [
7849
- ...skippedOutput,
7850
- `Markdown: ${result.errors.length} ${MARKDOWN_COMMAND_OUTPUT.ERROR_SUMMARY_SUFFIX}`,
7851
- ...errorLines
7852
- ].join("\n");
7853
- return { exitCode: 1, output, durationMs };
7854
8021
  }
7855
8022
  }
7856
- function formatSkippedFileScope(target) {
7857
- return `${MARKDOWN_COMMAND_OUTPUT.SKIPPED_FILE_SCOPE_PREFIX}: ${target.path} (${target.reason})`;
8023
+ function sortReuseFindings(findings) {
8024
+ return [...findings].sort(compareFindings);
8025
+ }
8026
+ function sortDupeFindings(findings) {
8027
+ return [...findings].sort(compareFindings);
8028
+ }
8029
+ function compareFindings(left, right) {
8030
+ return left.test.file.localeCompare(right.test.file) || left.test.line - right.test.line || left.kind.localeCompare(right.kind) || left.value.localeCompare(right.value);
8031
+ }
8032
+ function compareLiteralProblems(left, right) {
8033
+ return left.test.file.localeCompare(right.test.file) || left.test.line - right.test.line || left.literalKind.localeCompare(right.literalKind) || left.value.localeCompare(right.value);
8034
+ }
8035
+ function formatLiteralValue(kind, value) {
8036
+ return kind === "string" ? `"${value}"` : value;
8037
+ }
8038
+ function formatLoc(loc) {
8039
+ return `${loc.file}:${loc.line}`;
7858
8040
  }
7859
8041
 
7860
8042
  // src/validation/steps/typescript.ts
@@ -8082,42 +8264,119 @@ async function typescriptCommand(options) {
8082
8264
  }
8083
8265
  }
8084
8266
 
8267
+ // src/validation/languages/typescript.ts
8268
+ var TYPESCRIPT_LANGUAGE_NAME = "typescript";
8269
+ async function runLiteralStage(context) {
8270
+ if (context.skipLiteral) {
8271
+ const skipOutput = context.json ? LITERAL_SKIP_JSON_OUTPUT : LITERAL_SKIP_OUTPUT;
8272
+ return { exitCode: 0, output: context.quiet ? "" : skipOutput };
8273
+ }
8274
+ return literalCommand({
8275
+ cwd: context.cwd,
8276
+ files: context.files,
8277
+ quiet: context.quiet,
8278
+ json: context.json
8279
+ });
8280
+ }
8281
+ var typescriptValidationLanguage = {
8282
+ name: TYPESCRIPT_LANGUAGE_NAME,
8283
+ stages: [
8284
+ {
8285
+ name: VALIDATION_STAGE_DISPLAY_NAMES.CIRCULAR,
8286
+ failsPipeline: true,
8287
+ run: (context) => circularCommand({ cwd: context.cwd, quiet: context.quiet, json: context.json })
8288
+ },
8289
+ {
8290
+ name: VALIDATION_STAGE_DISPLAY_NAMES.KNIP,
8291
+ // Knip is informational: unused-code findings never fail the pipeline.
8292
+ failsPipeline: false,
8293
+ run: (context) => knipCommand({ cwd: context.cwd, quiet: context.quiet, json: context.json })
8294
+ },
8295
+ {
8296
+ name: VALIDATION_STAGE_DISPLAY_NAMES.ESLINT,
8297
+ failsPipeline: true,
8298
+ run: (context) => lintCommand({
8299
+ cwd: context.cwd,
8300
+ scope: context.scope,
8301
+ files: context.files,
8302
+ fix: context.fix,
8303
+ quiet: context.quiet,
8304
+ json: context.json
8305
+ })
8306
+ },
8307
+ {
8308
+ name: VALIDATION_STAGE_DISPLAY_NAMES.TYPESCRIPT,
8309
+ failsPipeline: true,
8310
+ run: (context) => typescriptCommand({
8311
+ cwd: context.cwd,
8312
+ scope: context.scope,
8313
+ files: context.files,
8314
+ quiet: context.quiet,
8315
+ json: context.json
8316
+ })
8317
+ },
8318
+ {
8319
+ name: VALIDATION_STAGE_DISPLAY_NAMES.LITERAL,
8320
+ failsPipeline: true,
8321
+ run: runLiteralStage
8322
+ }
8323
+ ]
8324
+ };
8325
+
8326
+ // src/validation/registry.ts
8327
+ var validationRegistry = {
8328
+ languages: [typescriptValidationLanguage, markdownValidationLanguage]
8329
+ };
8330
+ var validationPipelineStages = validationRegistry.languages.flatMap(
8331
+ (language) => language.stages
8332
+ );
8333
+ var VALIDATION_PIPELINE_TOTAL_STEPS = validationPipelineStages.length;
8334
+
8335
+ // src/commands/validation/format.ts
8336
+ var DURATION_THRESHOLD_MS = 1e3;
8337
+ var VALIDATION_SYMBOLS = {
8338
+ SUCCESS: "\u2713",
8339
+ FAILURE: "\u2717"
8340
+ };
8341
+ var VALIDATION_SUMMARY_STATUS = {
8342
+ PASSED: "passed",
8343
+ FAILED: "failed"
8344
+ };
8345
+ function formatDuration(ms) {
8346
+ if (ms < DURATION_THRESHOLD_MS) {
8347
+ return `${ms}ms`;
8348
+ }
8349
+ const seconds = ms / 1e3;
8350
+ return `${seconds.toFixed(1)}s`;
8351
+ }
8352
+ function formatSummary(options) {
8353
+ const { success, totalDurationMs } = options;
8354
+ const symbol = success ? VALIDATION_SYMBOLS.SUCCESS : VALIDATION_SYMBOLS.FAILURE;
8355
+ const status = success ? VALIDATION_SUMMARY_STATUS.PASSED : VALIDATION_SUMMARY_STATUS.FAILED;
8356
+ const duration = formatDuration(totalDurationMs);
8357
+ return `${symbol} Validation ${status} (${duration} total)`;
8358
+ }
8359
+
8085
8360
  // src/commands/validation/all.ts
8086
- var TOTAL_STEPS = VALIDATION_PIPELINE.TOTAL_STEPS;
8087
8361
  function formatStepWithTiming(stepNumber, result, quiet) {
8088
8362
  if (quiet || !result.output) return "";
8089
8363
  const timing = result.durationMs === void 0 ? "" : ` (${formatDuration(result.durationMs)})`;
8090
- return `[${stepNumber}/${TOTAL_STEPS}] ${result.output}${timing}`;
8364
+ return `[${stepNumber}/${VALIDATION_PIPELINE_TOTAL_STEPS}] ${result.output}${timing}`;
8091
8365
  }
8092
8366
  async function allCommand(options) {
8093
8367
  const { cwd, scope, files, fix, quiet = false, json, skipLiteral = false } = options;
8094
8368
  const startTime = Date.now();
8095
8369
  const outputs = [];
8096
8370
  let hasFailure = false;
8097
- const circularResult = await circularCommand({ cwd, quiet, json });
8098
- const circularOutput = formatStepWithTiming(1, circularResult, quiet);
8099
- if (circularOutput) outputs.push(circularOutput);
8100
- if (circularResult.exitCode !== 0) hasFailure = true;
8101
- const knipResult = await knipCommand({ cwd, quiet, json });
8102
- const knipOutput = formatStepWithTiming(2, knipResult, quiet);
8103
- if (knipOutput) outputs.push(knipOutput);
8104
- const lintResult = await lintCommand({ cwd, scope, files, fix, quiet, json });
8105
- const lintOutput = formatStepWithTiming(3, lintResult, quiet);
8106
- if (lintOutput) outputs.push(lintOutput);
8107
- if (lintResult.exitCode !== 0) hasFailure = true;
8108
- const tsResult = await typescriptCommand({ cwd, scope, files, quiet, json });
8109
- const tsOutput = formatStepWithTiming(4, tsResult, quiet);
8110
- if (tsOutput) outputs.push(tsOutput);
8111
- if (tsResult.exitCode !== 0) hasFailure = true;
8112
- const markdownResult = await markdownCommand({ cwd, files, quiet });
8113
- const markdownOutput = formatStepWithTiming(5, markdownResult, quiet);
8114
- if (markdownOutput) outputs.push(markdownOutput);
8115
- if (markdownResult.exitCode !== 0) hasFailure = true;
8116
- const literalSkipOutput = json ? LITERAL_SKIP_JSON_OUTPUT : LITERAL_SKIP_OUTPUT;
8117
- const literalResult = skipLiteral ? { exitCode: 0, output: quiet ? "" : literalSkipOutput } : await literalCommand({ cwd, files, quiet, json });
8118
- const literalOutput = formatStepWithTiming(6, literalResult, quiet);
8119
- if (literalOutput) outputs.push(literalOutput);
8120
- if (literalResult.exitCode !== 0) hasFailure = true;
8371
+ const context = { cwd, scope, files, fix, quiet, json, skipLiteral };
8372
+ let stepNumber = 0;
8373
+ for (const stage of validationPipelineStages) {
8374
+ stepNumber += 1;
8375
+ const result = await stage.run(context);
8376
+ const stepOutput = formatStepWithTiming(stepNumber, result, quiet);
8377
+ if (stepOutput) outputs.push(stepOutput);
8378
+ if (stage.failsPipeline && result.exitCode !== 0) hasFailure = true;
8379
+ }
8121
8380
  const totalDurationMs = Date.now() - startTime;
8122
8381
  if (!quiet) {
8123
8382
  const summary = formatSummary({ success: !hasFailure, totalDurationMs });
@@ -8268,7 +8527,7 @@ function serializeWithUpdatedInclude(target, include) {
8268
8527
  return serializeConfigFileSectionsWithSetIn(target, ALLOWLIST_INCLUDE_PATH, [...include]);
8269
8528
  }
8270
8529
 
8271
- // src/domains/validation/index.ts
8530
+ // src/interfaces/cli/validation.ts
8272
8531
  var validationCliDefinition = {
8273
8532
  domain: {
8274
8533
  commandName: "validation",
@@ -8517,17 +8776,24 @@ var validationDomain = {
8517
8776
  }
8518
8777
  };
8519
8778
 
8779
+ // src/interfaces/cli/registry.ts
8780
+ var CLI_DOMAINS = [
8781
+ auditDomain,
8782
+ claudeDomain,
8783
+ configDomain,
8784
+ sessionDomain,
8785
+ specDomain,
8786
+ validationDomain
8787
+ ];
8788
+
8520
8789
  // src/cli.ts
8521
8790
  installLifecycle();
8522
8791
  var require3 = createRequire2(import.meta.url);
8523
8792
  var { version } = require3("../package.json");
8524
8793
  var program = new Command();
8525
8794
  program.name("spx").description("Fast, deterministic CLI tool for spec workflow management").version(version);
8526
- auditDomain.register(program);
8527
- claudeDomain.register(program);
8528
- configDomain.register(program);
8529
- sessionDomain.register(program);
8530
- specDomain.register(program);
8531
- validationDomain.register(program);
8795
+ for (const domain of CLI_DOMAINS) {
8796
+ domain.register(program);
8797
+ }
8532
8798
  program.parse();
8533
8799
  //# sourceMappingURL=cli.js.map