diff --git a/Cargo.lock b/Cargo.lock index 966cae74..950ff925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2094,7 +2094,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10327.10010" +version = "2026.10328.106" dependencies = [ "dirs", "proptest", @@ -4372,7 +4372,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tnmsc" -version = "2026.10327.10010" +version = "2026.10328.106" dependencies = [ "clap", "dirs", @@ -4394,7 +4394,7 @@ dependencies = [ [[package]] name = "tnmsc-logger" -version = "2026.10327.10010" +version = "2026.10328.106" dependencies = [ "chrono", "napi", @@ -4406,7 +4406,7 @@ dependencies = [ [[package]] name = "tnmsc-md-compiler" -version = "2026.10327.10010" +version = "2026.10328.106" dependencies = [ "markdown", "napi", @@ -4421,7 +4421,7 @@ dependencies = [ [[package]] name = "tnmsc-script-runtime" -version = "2026.10327.10010" +version = "2026.10328.106" dependencies = [ "napi", "napi-build", diff --git a/Cargo.toml b/Cargo.toml index 17fc61b4..450ac3e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "2026.10327.10010" +version = "2026.10328.106" edition = "2024" license = "AGPL-3.0-only" authors = ["TrueNine"] diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index 276596f9..d6e039e1 100644 --- a/cli/npm/darwin-arm64/package.json +++ b/cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-arm64", - "version": "2026.10327.10010", + "version": "2026.10328.106", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index acbdcf7f..f6a14ee4 100644 --- a/cli/npm/darwin-x64/package.json +++ b/cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-x64", - "version": "2026.10327.10010", + "version": "2026.10328.106", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 6a73e982..f9689739 100644 --- a/cli/npm/linux-arm64-gnu/package.json +++ b/cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-arm64-gnu", - "version": "2026.10327.10010", + "version": "2026.10328.106", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 3fde5abd..90877414 100644 --- a/cli/npm/linux-x64-gnu/package.json +++ b/cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-x64-gnu", - "version": "2026.10327.10010", + "version": "2026.10328.106", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index 49198cc2..6a8f34be 100644 --- a/cli/npm/win32-x64-msvc/package.json +++ b/cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-win32-x64-msvc", - "version": "2026.10327.10010", + "version": "2026.10328.106", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index 298e5049..c063b83d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10327.10010", + "version": "2026.10328.106", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", @@ -48,20 +48,20 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { - "build": "run-s build:deps build:napi bundle finalize:bundle generate:schema check", + "build": "run-s build:deps build:napi bundle finalize:bundle generate:schema", "build:napi": "tsx ../scripts/copy-napi.ts", "build:deps": "pnpm -F @truenine/logger -F @truenine/md-compiler -F @truenine/script-runtime run build", "bundle": "tsx ../scripts/build-quiet.ts", "check": "run-p typecheck lint", "finalize:bundle": "tsx scripts/finalize-bundle.ts", "generate:schema": "tsx scripts/generate-schema.ts", - "lint": "eslint --cache .", - "prepublishOnly": "run-s build", + "lint": "eslint --cache --cache-location node_modules/.cache/.eslintcache .", + "prepublishOnly": "run-s build check", "test": "run-s build:deps test:run", "test:native-cleanup-smoke": "tsx scripts/cleanup-native-smoke.ts", "test:run": "vitest run", "benchmark:cleanup": "tsx scripts/benchmark-cleanup.ts", - "lintfix": "eslint --fix --cache .", + "lintfix": "eslint --fix --cache --cache-location node_modules/.cache/.eslintcache .", "typecheck": "tsc --noEmit -p tsconfig.lib.json" }, "dependencies": { diff --git a/cli/src/ProtectedDeletionGuard.ts b/cli/src/ProtectedDeletionGuard.ts index 1175ebde..b79926ee 100644 --- a/cli/src/ProtectedDeletionGuard.ts +++ b/cli/src/ProtectedDeletionGuard.ts @@ -282,25 +282,24 @@ function collectWorkspaceReservedRules( for (const projectRoot of projectRoots) rules.push(createProtectedPathRule(projectRoot, 'direct', 'workspace project root', 'workspace-project-root')) - if (includeReservedWorkspaceContentRoots) { - rules.push( - createProtectedPathRule( - path.join(workspaceDir, 'aindex', 'dist', '**', '*.mdx'), - 'direct', - 'reserved workspace aindex dist mdx files', - 'workspace-reserved', - 'glob' - ), - createProtectedPathRule( - path.join(workspaceDir, 'aindex', 'app', '**', '*.mdx'), - 'direct', - 'reserved workspace aindex app mdx files', - 'workspace-reserved', - 'glob' - ) - ) + if (!includeReservedWorkspaceContentRoots) return rules + + rules.push(createProtectedPathRule( + path.join(workspaceDir, 'aindex', 'dist', '**', '*.mdx'), + 'direct', + 'reserved workspace aindex dist mdx files', + 'workspace-reserved', + 'glob' + )) + for (const seriesName of ['app', 'ext', 'arch'] as const) { + rules.push(createProtectedPathRule( + path.join(workspaceDir, 'aindex', seriesName, '**', '*.mdx'), + 'direct', + `reserved workspace aindex ${seriesName} mdx files`, + 'workspace-reserved', + 'glob' + )) } - return rules } diff --git a/cli/src/aindex-project-series.ts b/cli/src/aindex-project-series.ts new file mode 100644 index 00000000..0cfa3ddf --- /dev/null +++ b/cli/src/aindex-project-series.ts @@ -0,0 +1,72 @@ +import type {AindexProjectSeriesName, PluginOptions} from '@/plugins/plugin-core' +import {AINDEX_PROJECT_SERIES_NAMES} from '@/plugins/plugin-core' + +export interface AindexProjectSeriesConfig { + readonly name: AindexProjectSeriesName + readonly src: string + readonly dist: string +} + +export interface AindexProjectSeriesProjectRef { + readonly projectName: string + readonly seriesName: AindexProjectSeriesName + readonly seriesDir: string +} + +export interface AindexProjectSeriesProjectNameConflict { + readonly projectName: string + readonly refs: readonly AindexProjectSeriesProjectRef[] +} + +type AindexProjectSeriesOptions = Required['aindex'] + +export function isAindexProjectSeriesName(value: string): value is AindexProjectSeriesName { + return AINDEX_PROJECT_SERIES_NAMES.includes(value as AindexProjectSeriesName) +} + +export function resolveAindexProjectSeriesConfigs( + options: Required +): readonly AindexProjectSeriesConfig[] { + return AINDEX_PROJECT_SERIES_NAMES.map(name => buildAindexProjectSeriesConfig(options.aindex, name)) +} + +export function resolveAindexProjectSeriesConfig( + options: Required, + seriesName: AindexProjectSeriesName +): AindexProjectSeriesConfig { + return buildAindexProjectSeriesConfig(options.aindex, seriesName) +} + +export function collectAindexProjectSeriesProjectNameConflicts( + refs: readonly AindexProjectSeriesProjectRef[] +): readonly AindexProjectSeriesProjectNameConflict[] { + const refsByProjectName = new Map() + + for (const ref of refs) { + const existingRefs = refsByProjectName.get(ref.projectName) + if (existingRefs == null) refsByProjectName.set(ref.projectName, [ref]) + else existingRefs.push(ref) + } + + return Array.from(refsByProjectName.entries(), ([projectName, projectRefs]) => ({ + projectName, + refs: [...projectRefs] + .sort((left, right) => left.seriesName.localeCompare(right.seriesName)) + })) + .filter(conflict => { + const uniqueSeriesNames = new Set(conflict.refs.map(ref => ref.seriesName)) + return uniqueSeriesNames.size > 1 + }) + .sort((left, right) => left.projectName.localeCompare(right.projectName)) +} + +function buildAindexProjectSeriesConfig( + aindexOptions: AindexProjectSeriesOptions, + seriesName: AindexProjectSeriesName +): AindexProjectSeriesConfig { + return { + name: seriesName, + src: aindexOptions[seriesName].src, + dist: aindexOptions[seriesName].dist + } +} diff --git a/cli/src/commands/CleanupUtils.test.ts b/cli/src/commands/CleanupUtils.test.ts index 127b0389..fb6919e2 100644 --- a/cli/src/commands/CleanupUtils.test.ts +++ b/cli/src/commands/CleanupUtils.test.ts @@ -446,7 +446,7 @@ describe('collectDeletionTargets', () => { expect(result.violations).toEqual([expect.objectContaining({ targetPath: path.resolve(path.join(workspaceDir, 'aindex', 'app')), protectionMode: 'direct', - protectedPath: path.resolve(path.join(workspaceDir, 'aindex', 'app', 'workspace.src.mdx')) + protectedPath: path.resolve(protectedAppMdxFile) })]) } finally { diff --git a/cli/src/config.ts b/cli/src/config.ts index a606f80f..fb6a8eca 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -57,8 +57,8 @@ const DEFAULT_AINDEX: Required = { commands: {src: 'commands', dist: 'dist/commands'}, subAgents: {src: 'subagents', dist: 'dist/subagents'}, rules: {src: 'rules', dist: 'dist/rules'}, - globalPrompt: {src: 'app/global.src.mdx', dist: 'dist/global.mdx'}, - workspacePrompt: {src: 'app/workspace.src.mdx', dist: 'dist/workspace.mdx'}, + globalPrompt: {src: 'global.src.mdx', dist: 'dist/global.mdx'}, + workspacePrompt: {src: 'workspace.src.mdx', dist: 'dist/workspace.mdx'}, app: {src: 'app', dist: 'dist/app'}, ext: {src: 'ext', dist: 'dist/ext'}, arch: {src: 'arch', dist: 'dist/arch'} diff --git a/cli/src/core/cleanup.rs b/cli/src/core/cleanup.rs index f4017ce6..22796552 100644 --- a/cli/src/core/cleanup.rs +++ b/cli/src/core/cleanup.rs @@ -305,7 +305,9 @@ fn compile_rule(rule: &ProtectedRuleDto) -> CompiledProtectedRule { reason: rule.reason.clone(), source: rule.source.clone(), comparison_keys: build_comparison_keys(&rule.path), - specificity: normalized_path.trim_end_matches(std::path::MAIN_SEPARATOR).len(), + specificity: normalized_path + .trim_end_matches(std::path::MAIN_SEPARATOR) + .len(), normalized_path, } } @@ -333,8 +335,12 @@ fn dedupe_and_compile_rules(rules: &[ProtectedRuleDto]) -> Vec std::cmp::Ordering::Less, - (ProtectionModeDto::Direct, ProtectionModeDto::Recursive) => std::cmp::Ordering::Greater, + (ProtectionModeDto::Recursive, ProtectionModeDto::Direct) => { + std::cmp::Ordering::Less + } + (ProtectionModeDto::Direct, ProtectionModeDto::Recursive) => { + std::cmp::Ordering::Greater + } _ => std::cmp::Ordering::Equal, }) .then_with(|| a.path.cmp(&b.path)) @@ -611,7 +617,9 @@ fn collect_workspace_reserved_rules( None, ), create_protected_rule( - &path_to_string(&resolve_absolute_path(&format!("{workspace_dir}/knowladge"))), + &path_to_string(&resolve_absolute_path(&format!( + "{workspace_dir}/knowladge" + ))), ProtectionModeDto::Direct, "reserved workspace knowladge root", "workspace-reserved", @@ -637,19 +645,24 @@ fn collect_workspace_reserved_rules( "workspace-reserved", Some(ProtectionRuleMatcherDto::Glob), )); - rules.push(create_protected_rule( - &format!("{workspace_dir}/aindex/app/**/*.mdx"), - ProtectionModeDto::Direct, - "reserved workspace aindex app mdx files", - "workspace-reserved", - Some(ProtectionRuleMatcherDto::Glob), - )); + for series_name in ["app", "ext", "arch"] { + rules.push(create_protected_rule( + &format!("{workspace_dir}/aindex/{series_name}/**/*.mdx"), + ProtectionModeDto::Direct, + &format!("reserved workspace aindex {series_name} mdx files"), + "workspace-reserved", + Some(ProtectionRuleMatcherDto::Glob), + )); + } } rules } -fn create_guard(snapshot: &CleanupSnapshot, rules: &[ProtectedRuleDto]) -> Result { +fn create_guard( + snapshot: &CleanupSnapshot, + rules: &[ProtectedRuleDto], +) -> Result { let mut all_rules = collect_built_in_dangerous_path_rules(); all_rules.extend(collect_workspace_reserved_rules( &snapshot.workspace_dir, @@ -677,7 +690,8 @@ fn is_rule_match(target_key: &str, rule_key: &str, protection_mode: ProtectionMo match protection_mode { ProtectionModeDto::Direct => is_same_or_child_path(rule_key, target_key), ProtectionModeDto::Recursive => { - is_same_or_child_path(target_key, rule_key) || is_same_or_child_path(rule_key, target_key) + is_same_or_child_path(target_key, rule_key) + || is_same_or_child_path(rule_key, target_key) } } } @@ -763,7 +777,10 @@ fn partition_deletion_targets(paths: &[String], guard: &ProtectedDeletionGuard) safe_paths.sort(); violations.sort_by(|a, b| a.target_path.cmp(&b.target_path)); - PartitionResult { safe_paths, violations } + PartitionResult { + safe_paths, + violations, + } } fn compact_deletion_targets(files: &[String], dirs: &[String]) -> (Vec, Vec) { @@ -783,7 +800,8 @@ fn compact_deletion_targets(files: &[String], dirs: &[String]) -> (Vec, .collect::>(); let mut sorted_dir_entries = dirs_by_key.into_iter().collect::>(); - sorted_dir_entries.sort_by(|(left_key, _), (right_key, _)| left_key.len().cmp(&right_key.len())); + sorted_dir_entries + .sort_by(|(left_key, _), (right_key, _)| left_key.len().cmp(&right_key.len())); let mut compacted_dirs: HashMap = HashMap::new(); for (dir_key, dir_path) in sorted_dir_entries { @@ -869,7 +887,9 @@ fn should_exclude_cleanup_match(matched_path: &str, target: &CleanupTargetDto) - fn default_protection_mode_for_target(target: &CleanupTargetDto) -> ProtectionModeDto { target.protection_mode.unwrap_or(match target.kind { CleanupTargetKindDto::File => ProtectionModeDto::Direct, - CleanupTargetKindDto::Directory | CleanupTargetKindDto::Glob => ProtectionModeDto::Recursive, + CleanupTargetKindDto::Directory | CleanupTargetKindDto::Glob => { + ProtectionModeDto::Recursive + } }) } @@ -877,8 +897,11 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { let mut delete_files = HashSet::new(); let mut delete_dirs = HashSet::new(); let mut protected_rules = snapshot.protected_rules.clone(); - let mut exclude_scan_globs = - BTreeSet::from_iter(DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS.iter().map(|value| (*value).to_string())); + let mut exclude_scan_globs = BTreeSet::from_iter( + DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS + .iter() + .map(|value| (*value).to_string()), + ); let mut output_path_owners = HashMap::>::new(); for plugin_snapshot in &snapshot.plugin_snapshots { @@ -1022,11 +1045,16 @@ pub fn perform_cleanup(snapshot: CleanupSnapshot) -> Result>(); - errors.extend(delete_result.dir_errors.into_iter().map(|error| CleanupErrorDto { - path: error.path, - kind: CleanupErrorKindDto::Directory, - error: error.error, - })); + errors.extend( + delete_result + .dir_errors + .into_iter() + .map(|error| CleanupErrorDto { + path: error.path, + kind: CleanupErrorKindDto::Directory, + error: error.error, + }), + ); Ok(CleanupExecutionResultDto { deleted_files: delete_result.deleted_files.len(), @@ -1047,7 +1075,8 @@ mod napi_binding { use super::{CleanupExecutionResultDto, CleanupPlan, CleanupSnapshot}; fn parse_snapshot(snapshot_json: String) -> napi::Result { - serde_json::from_str(&snapshot_json).map_err(|error| napi::Error::from_reason(error.to_string())) + serde_json::from_str(&snapshot_json) + .map_err(|error| napi::Error::from_reason(error.to_string())) } fn serialize_result(result: &T) -> napi::Result { @@ -1173,7 +1202,10 @@ mod tests { let snapshot = single_plugin_snapshot( &workspace_dir, - vec![path_to_string(&direct_file), path_to_string(&recursive_file)], + vec![ + path_to_string(&direct_file), + path_to_string(&recursive_file), + ], CleanupDeclarationsDto { protect: vec![ CleanupTargetDto { @@ -1199,10 +1231,11 @@ mod tests { let plan = plan_cleanup(snapshot).unwrap(); assert!(plan.files_to_delete.contains(&path_to_string(&direct_file))); - assert!(plan - .violations - .iter() - .any(|violation| violation.target_path == path_to_string(&recursive_file))); + assert!( + plan.violations + .iter() + .any(|violation| violation.target_path == path_to_string(&recursive_file)) + ); } #[test] @@ -1232,7 +1265,10 @@ mod tests { let plan = plan_cleanup(snapshot).unwrap(); assert!(plan.dirs_to_delete.is_empty()); assert_eq!(plan.violations.len(), 1); - assert_eq!(plan.violations[0].protected_path, path_to_string(&protected_file)); + assert_eq!( + plan.violations[0].protected_path, + path_to_string(&protected_file) + ); } #[cfg(unix)] @@ -1264,10 +1300,11 @@ mod tests { let plan = plan_cleanup(snapshot).unwrap(); assert!(plan.dirs_to_delete.is_empty()); - assert!(plan - .violations - .iter() - .any(|violation| violation.target_path == path_to_string(&symlink_path))); + assert!( + plan.violations + .iter() + .any(|violation| violation.target_path == path_to_string(&symlink_path)) + ); } #[test] diff --git a/cli/src/core/config/mod.rs b/cli/src/core/config/mod.rs index 5129bdbc..19fe1a4c 100644 --- a/cli/src/core/config/mod.rs +++ b/cli/src/core/config/mod.rs @@ -1119,8 +1119,8 @@ mod tests { "commands": {"src": "src/commands", "dist": "dist/commands"}, "subAgents": {"src": "src/agents", "dist": "dist/agents"}, "rules": {"src": "src/rules", "dist": "dist/rules"}, - "globalPrompt": {"src": "app/global.src.mdx", "dist": "dist/global.mdx"}, - "workspacePrompt": {"src": "app/workspace.src.mdx", "dist": "dist/workspace.mdx"}, + "globalPrompt": {"src": "global.src.mdx", "dist": "dist/global.mdx"}, + "workspacePrompt": {"src": "workspace.src.mdx", "dist": "dist/workspace.mdx"}, "app": {"src": "app", "dist": "dist/app"}, "ext": {"src": "ext", "dist": "dist/ext"}, "arch": {"src": "arch", "dist": "dist/arch"} diff --git a/cli/src/inputs/effect-md-cleanup.ts b/cli/src/inputs/effect-md-cleanup.ts index b32f9210..02a02575 100644 --- a/cli/src/inputs/effect-md-cleanup.ts +++ b/cli/src/inputs/effect-md-cleanup.ts @@ -4,6 +4,7 @@ import type { InputEffectContext, InputEffectResult } from '../plugins/plugin-core' +import {resolveAindexProjectSeriesConfigs} from '@/aindex-project-series' import {buildFileOperationDiagnostic} from '@/diagnostics' import {AbstractInputCapability} from '../plugins/plugin-core' @@ -19,15 +20,17 @@ export class MarkdownWhitespaceCleanupEffectInputCapability extends AbstractInpu } private async cleanupWhitespace(ctx: InputEffectContext): Promise { - const {fs, path, aindexDir, dryRun, logger} = ctx + const {fs, path, aindexDir, dryRun, logger, userConfigOptions} = ctx const modifiedFiles: string[] = [] const skippedFiles: string[] = [] const errors: {path: string, error: Error}[] = [] + const projectSeriesDirs = resolveAindexProjectSeriesConfigs(userConfigOptions) + .map(series => path.join(aindexDir, series.src)) const dirsToScan = [ path.join(aindexDir, 'src'), - path.join(aindexDir, 'app'), + ...projectSeriesDirs, path.join(aindexDir, 'dist') ] diff --git a/cli/src/inputs/effect-orphan-cleanup.test.ts b/cli/src/inputs/effect-orphan-cleanup.test.ts index 5345dd4b..6bb0a529 100644 --- a/cli/src/inputs/effect-orphan-cleanup.test.ts +++ b/cli/src/inputs/effect-orphan-cleanup.test.ts @@ -67,7 +67,7 @@ describe('orphan file cleanup effect', () => { } }) - it('blocks deleting dist command mdx files when only a legacy cn source remains', async () => { + it('deletes dist command mdx files when only a legacy cn source remains', async () => { const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-orphan-cleanup-legacy-test-')) const srcDir = path.join(tempWorkspace, 'aindex', 'commands') const distDir = path.join(tempWorkspace, 'aindex', 'dist', 'commands') @@ -80,8 +80,11 @@ describe('orphan file cleanup effect', () => { fs.writeFileSync(distFile, 'Compiled prompt', 'utf8') const plugin = new OrphanFileCleanupEffectInputCapability() - await expect(plugin.executeEffects(createContext(tempWorkspace))).rejects.toThrow('Protected deletion guard blocked orphan-file-cleanup') - expect(fs.existsSync(distFile)).toBe(true) + const [result] = await plugin.executeEffects(createContext(tempWorkspace)) + + expect(result?.success).toBe(true) + expect(fs.existsSync(distFile)).toBe(false) + expect(result?.deletedDirs ?? []).toContain(path.join(tempWorkspace, 'aindex', 'dist', 'commands')) } finally { fs.rmSync(tempWorkspace, {recursive: true, force: true}) @@ -159,4 +162,32 @@ describe('orphan file cleanup effect', () => { fs.rmSync(tempWorkspace, {recursive: true, force: true}) } }) + + it('cleans orphaned ext and arch dist files using matching series source roots', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-orphan-cleanup-series-')) + const extSrcFile = path.join(tempWorkspace, 'aindex', 'ext', 'plugin-a', 'agt.src.mdx') + const extDistFile = path.join(tempWorkspace, 'aindex', 'dist', 'ext', 'plugin-a', 'agt.mdx') + const archDistFile = path.join(tempWorkspace, 'aindex', 'dist', 'arch', 'system-a', 'agt.mdx') + + try { + fs.mkdirSync(path.dirname(extSrcFile), {recursive: true}) + fs.mkdirSync(path.dirname(extDistFile), {recursive: true}) + fs.mkdirSync(path.dirname(archDistFile), {recursive: true}) + fs.writeFileSync(extSrcFile, '---\ndescription: ext\n---\nExt prompt', 'utf8') + fs.writeFileSync(extDistFile, 'Ext dist', 'utf8') + fs.writeFileSync(archDistFile, 'Arch dist', 'utf8') + + const plugin = new OrphanFileCleanupEffectInputCapability() + const [result] = await plugin.executeEffects(createContext(tempWorkspace)) + + expect(result?.success).toBe(true) + expect(fs.existsSync(extDistFile)).toBe(true) + expect(fs.existsSync(archDistFile)).toBe(false) + expect(result?.deletedDirs ?? []).toContain(path.join(tempWorkspace, 'aindex', 'dist', 'arch')) + expect(result?.deletedFiles ?? []).not.toContain(extDistFile) + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + } + }) }) diff --git a/cli/src/inputs/effect-orphan-cleanup.ts b/cli/src/inputs/effect-orphan-cleanup.ts index 1e311ae9..4480e140 100644 --- a/cli/src/inputs/effect-orphan-cleanup.ts +++ b/cli/src/inputs/effect-orphan-cleanup.ts @@ -1,4 +1,5 @@ import type {InputCapabilityContext, InputCollectedContext, InputEffectContext, InputEffectResult} from '../plugins/plugin-core' +import {resolveAindexProjectSeriesConfigs} from '@/aindex-project-series' import {buildFileOperationDiagnostic} from '@/diagnostics' import {compactDeletionTargets} from '../cleanup/delete-targets' import {deleteTargets} from '../core/desk-paths' @@ -15,7 +16,7 @@ export interface OrphanCleanupEffectResult extends InputEffectResult { readonly deletedDirs: string[] } -const OrphanCleanupDistSubDirs = ['skills', 'commands', 'agents', 'app'] as const +const OrphanCleanupDistSubDirs = ['skills', 'commands', 'agents', 'app', 'ext', 'arch'] as const type OrphanCleanupSubDir = (typeof OrphanCleanupDistSubDirs)[number] @@ -37,6 +38,7 @@ export class OrphanFileCleanupEffectInputCapability extends AbstractInputCapabil return createProtectedDeletionGuard({ workspaceDir: ctx.workspaceDir, aindexDir: ctx.aindexDir, + includeReservedWorkspaceContentRoots: false, rules: [ ...collectConfiguredAindexInputRules(ctx.userConfigOptions, ctx.aindexDir, { workspaceDir: ctx.workspaceDir @@ -87,11 +89,14 @@ export class OrphanFileCleanupEffectInputCapability extends AbstractInputCapabil } const aindexConfig = userConfigOptions.aindex + const projectSeries = resolveAindexProjectSeriesConfigs(userConfigOptions) const srcPaths: OrphanCleanupSourcePaths = { skills: aindexConfig?.skills?.src ?? 'skills', commands: aindexConfig?.commands?.src ?? 'commands', agents: aindexConfig?.subAgents?.src ?? 'subagents', - app: aindexConfig?.app?.src ?? 'app' + app: projectSeries.find(series => series.name === 'app')?.src ?? 'app', + ext: projectSeries.find(series => series.name === 'ext')?.src ?? 'ext', + arch: projectSeries.find(series => series.name === 'arch')?.src ?? 'arch' } const plan = this.buildDeletionPlan(ctx, distDir, srcPaths) @@ -276,6 +281,8 @@ export class OrphanFileCleanupEffectInputCapability extends AbstractInputCapabil case 'commands': case 'agents': case 'app': + case 'ext': + case 'arch': return relativeDir === '.' ? SourcePromptFileExtensions.map(extension => nodePath.join(aindexDir, srcPath, `${baseName}${extension}`)) : SourcePromptFileExtensions.map(extension => nodePath.join(aindexDir, srcPath, relativeDir, `${baseName}${extension}`)) diff --git a/cli/src/inputs/input-aindex.test.ts b/cli/src/inputs/input-aindex.test.ts index a74ea411..8185a65c 100644 --- a/cli/src/inputs/input-aindex.test.ts +++ b/cli/src/inputs/input-aindex.test.ts @@ -9,19 +9,22 @@ import {AindexInputCapability} from './input-aindex' function createLoggerMock(): { readonly logger: InputCapabilityContext['logger'] + readonly error: ReturnType readonly warn: ReturnType } { + const error = vi.fn() const warn = vi.fn() return { logger: { - error: vi.fn(), + error, warn, info: vi.fn(), debug: vi.fn(), trace: vi.fn(), fatal: vi.fn() }, + error, warn } } @@ -40,11 +43,15 @@ function createContext( } as InputCapabilityContext } -function createAindexProject(tempWorkspace: string, projectName: string): { +function createAindexProject( + tempWorkspace: string, + projectName: string, + series: 'app' | 'ext' | 'arch' = 'app' +): { readonly configDir: string } { - const distProjectDir = path.join(tempWorkspace, 'aindex', 'dist', 'app', projectName) - const configDir = path.join(tempWorkspace, 'aindex', 'app', projectName) + const distProjectDir = path.join(tempWorkspace, 'aindex', 'dist', series, projectName) + const configDir = path.join(tempWorkspace, 'aindex', series, projectName) fs.mkdirSync(distProjectDir, {recursive: true}) fs.mkdirSync(configDir, {recursive: true}) @@ -53,7 +60,7 @@ function createAindexProject(tempWorkspace: string, projectName: string): { } describe('aindex input capability project config loading', () => { - it('loads project.json5 using JSON5 features without any jsonc fallback', () => { + it('loads project.json5 using JSON5 features without any jsonc fallback', async () => { const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-aindex-project-json5-')) const {logger, warn} = createLoggerMock() @@ -70,7 +77,7 @@ describe('aindex input capability project config loading', () => { '' ].join('\n'), 'utf8') - const result = new AindexInputCapability().collect(createContext(tempWorkspace, logger)) + const result = await new AindexInputCapability().collect(createContext(tempWorkspace, logger)) const project = result.workspace?.projects[0] expect(project?.name).toBe('project-a') @@ -87,7 +94,7 @@ describe('aindex input capability project config loading', () => { } }) - it('ignores legacy project.jsonc after the hard cut', () => { + it('ignores legacy project.jsonc after the hard cut', async () => { const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-aindex-project-jsonc-legacy-')) const {logger, warn} = createLoggerMock() @@ -95,7 +102,7 @@ describe('aindex input capability project config loading', () => { const {configDir} = createAindexProject(tempWorkspace, 'project-b') fs.writeFileSync(path.join(configDir, 'project.jsonc'), '{"includeSeries":["legacy"]}\n', 'utf8') - const result = new AindexInputCapability().collect(createContext(tempWorkspace, logger)) + const result = await new AindexInputCapability().collect(createContext(tempWorkspace, logger)) const project = result.workspace?.projects[0] expect(project?.name).toBe('project-b') @@ -107,7 +114,7 @@ describe('aindex input capability project config loading', () => { } }) - it('emits JSON5 diagnostics for invalid project.json5 syntax', () => { + it('emits JSON5 diagnostics for invalid project.json5 syntax', async () => { const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-aindex-project-json5-invalid-')) const {logger, warn} = createLoggerMock() @@ -115,7 +122,7 @@ describe('aindex input capability project config loading', () => { const {configDir} = createAindexProject(tempWorkspace, 'project-c') fs.writeFileSync(path.join(configDir, 'project.json5'), '{includeSeries: [\'broken\',]} trailing', 'utf8') - const result = new AindexInputCapability().collect(createContext(tempWorkspace, logger)) + const result = await new AindexInputCapability().collect(createContext(tempWorkspace, logger)) const project = result.workspace?.projects[0] const diagnostic = warn.mock.calls[0]?.[0] @@ -132,4 +139,47 @@ describe('aindex input capability project config loading', () => { fs.rmSync(tempWorkspace, {recursive: true, force: true}) } }) + + it('collects app, ext, and arch projects with series-aware metadata', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-aindex-project-series-')) + const {logger} = createLoggerMock() + + try { + createAindexProject(tempWorkspace, 'project-a', 'app') + createAindexProject(tempWorkspace, 'plugin-a', 'ext') + createAindexProject(tempWorkspace, 'system-a', 'arch') + + const result = await new AindexInputCapability().collect(createContext(tempWorkspace, logger)) + const projects = result.workspace?.projects ?? [] + + expect(projects.map(project => `${project.promptSeries}:${project.name}`)).toEqual([ + 'app:project-a', + 'ext:plugin-a', + 'arch:system-a' + ]) + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + } + }) + + it('fails fast when app, ext, and arch reuse the same project name', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-aindex-project-conflict-')) + const {logger, error} = createLoggerMock() + + try { + createAindexProject(tempWorkspace, 'project-a', 'app') + createAindexProject(tempWorkspace, 'project-a', 'ext') + + await expect(new AindexInputCapability().collect(createContext(tempWorkspace, logger))) + .rejects + .toThrow('Aindex project series name conflict') + expect(error).toHaveBeenCalledWith(expect.objectContaining({ + code: 'AINDEX_PROJECT_SERIES_NAME_CONFLICT' + })) + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + } + }) }) diff --git a/cli/src/inputs/input-aindex.ts b/cli/src/inputs/input-aindex.ts index b67f6702..db9ed5a9 100644 --- a/cli/src/inputs/input-aindex.ts +++ b/cli/src/inputs/input-aindex.ts @@ -1,6 +1,11 @@ import type {InputCapabilityContext, InputCollectedContext, Project, ProjectConfig, Workspace} from '../plugins/plugin-core' +import type {AindexProjectSeriesConfig} from '@/aindex-project-series' import JSON5 from 'json5' +import { + collectAindexProjectSeriesProjectNameConflicts, + resolveAindexProjectSeriesConfigs +} from '@/aindex-project-series' import { buildConfigDiagnostic, buildFileOperationDiagnostic, @@ -10,6 +15,7 @@ import {AbstractInputCapability, FilePathKind} from '../plugins/plugin-core' export class AindexInputCapability extends AbstractInputCapability { private static readonly projectConfigFileName = 'project.json5' + private static readonly conflictingProjectSeriesCode = 'AINDEX_PROJECT_SERIES_NAME_CONFLICT' constructor() { super('AindexInputCapability') @@ -78,73 +84,164 @@ export class AindexInputCapability extends AbstractInputCapability { } } - collect(ctx: InputCapabilityContext): Partial { - const {userConfigOptions: options, logger, fs, path} = ctx - const {workspaceDir, aindexDir} = this.resolveBasePaths(options) - - const aindexProjectsDir = this.resolveAindexPath(options.aindex.app.dist, aindexDir) - - const aindexName = path.basename(aindexDir) - - const aindexProjects: Project[] = [] + private async scanSeriesProjects( + ctx: InputCapabilityContext, + workspaceDir: string, + aindexDir: string, + aindexName: string, + projectNameSource: readonly AindexProjectSeriesConfig[] + ): Promise { + const {logger, fs, path} = ctx + const projectGroups = await Promise.all(projectNameSource.map(async series => { + const aindexProjectsDir = this.resolveAindexPath(series.dist, aindexDir) + const distDirStat = await fs.promises.stat(aindexProjectsDir).catch(() => void 0) + if (!(distDirStat?.isDirectory() === true)) return [] - if (fs.existsSync(aindexProjectsDir) && fs.statSync(aindexProjectsDir).isDirectory()) { try { - const entries = fs.readdirSync(aindexProjectsDir, {withFileTypes: true}) + const entries = (await fs.promises.readdir(aindexProjectsDir, {withFileTypes: true})) + .filter(entry => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)) + const projects: Project[] = [] + for (const entry of entries) { - if (entry.isDirectory()) { - const isTheAindex = entry.name === aindexName - const projectConfig = this.loadProjectConfig(entry.name, aindexDir, options.aindex.app.src, fs, path, logger) - - aindexProjects.push({ - name: entry.name, - ...isTheAindex && {isPromptSourceProject: true}, - ...projectConfig != null && {projectConfig}, - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: entry.name, - basePath: workspaceDir, - getDirectoryName: () => entry.name, - getAbsolutePath: () => path.resolve(workspaceDir, entry.name) - } - }) - } + const isTheAindex = entry.name === aindexName + const projectConfig = this.loadProjectConfig(entry.name, aindexDir, series.src, fs, path, logger) + + projects.push({ + name: entry.name, + promptSeries: series.name, + ...isTheAindex && {isPromptSourceProject: true}, + ...projectConfig != null && {projectConfig}, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: entry.name, + basePath: workspaceDir, + getDirectoryName: () => entry.name, + getAbsolutePath: () => path.resolve(workspaceDir, entry.name) + } + }) } + + return projects } catch (e) { logger.error(buildFileOperationDiagnostic({ code: 'AINDEX_PROJECT_DIRECTORY_SCAN_FAILED', - title: 'Failed to scan aindex projects directory', + title: `Failed to scan aindex ${series.name} projects directory`, operation: 'scan', - targetKind: 'aindex projects directory', + targetKind: `aindex ${series.name} projects directory`, path: aindexProjectsDir, error: e })) + + return [] } + })) + + return projectGroups.flat() + } + + private loadFallbackProjectConfig( + projectName: string, + aindexDir: string, + ctx: Pick + ): ProjectConfig | undefined { + for (const series of resolveAindexProjectSeriesConfigs(ctx.userConfigOptions)) { + const config = this.loadProjectConfig(projectName, aindexDir, series.src, ctx.fs, ctx.path, ctx.logger) + if (config != null) return config } + return void 0 + } + + private assertNoCrossSeriesProjectNameConflicts( + ctx: Pick, + aindexDir: string, + projectSeries: readonly AindexProjectSeriesConfig[] + ): void { + const {logger, fs, path} = ctx + const projectRefs = projectSeries.flatMap(series => { + const seriesSourceDir = path.join(aindexDir, series.src) + if (!(fs.existsSync(seriesSourceDir) && fs.statSync(seriesSourceDir).isDirectory())) return [] + + return fs + .readdirSync(seriesSourceDir, {withFileTypes: true}) + .filter(entry => entry.isDirectory()) + .map(entry => ({ + projectName: entry.name, + seriesName: series.name, + seriesDir: path.join(seriesSourceDir, entry.name) + })) + }) + const conflicts = collectAindexProjectSeriesProjectNameConflicts(projectRefs) + if (conflicts.length === 0) return + + logger.error(buildConfigDiagnostic({ + code: AindexInputCapability.conflictingProjectSeriesCode, + title: 'Project names must be unique across app, ext, and arch', + reason: diagnosticLines( + 'tnmsc maps project-scoped outputs back to workspace project names, so app/ext/arch cannot reuse the same directory name.', + `Conflicting project names: ${conflicts.map(conflict => conflict.projectName).join(', ')}` + ), + exactFix: diagnosticLines( + 'Rename the conflicting project directory in one of the app/ext/arch source trees and rerun tnmsc.' + ), + possibleFixes: conflicts.map(conflict => diagnosticLines( + `"${conflict.projectName}" is currently declared in: ${conflict.refs.map(ref => `${ref.seriesName} (${ref.seriesDir})`).join(', ')}` + )), + details: { + aindexDir, + conflicts: conflicts.map(conflict => ({ + projectName: conflict.projectName, + refs: conflict.refs.map(ref => ({ + seriesName: ref.seriesName, + seriesDir: ref.seriesDir + })) + })) + } + })) + + throw new Error('Aindex project series name conflict') + } + + async collect(ctx: InputCapabilityContext): Promise> { + const {userConfigOptions: options, logger, fs, path} = ctx + const {workspaceDir, aindexDir} = this.resolveBasePaths(options) + const aindexName = path.basename(aindexDir) + const projectSeries = resolveAindexProjectSeriesConfigs(options) + + // Project outputs intentionally collapse to /, so + // app/ext/arch must never reuse the same project directory name. + this.assertNoCrossSeriesProjectNameConflicts(ctx, aindexDir, projectSeries) + + const aindexProjects = await this.scanSeriesProjects(ctx, workspaceDir, aindexDir, aindexName, projectSeries) + if (aindexProjects.length === 0 && fs.existsSync(workspaceDir) && fs.statSync(workspaceDir).isDirectory()) { - logger.debug('no projects in dist/app/, falling back to workspace scan', {workspaceDir}) + logger.debug('no projects in dist/app, dist/ext, or dist/arch; falling back to workspace scan', {workspaceDir}) try { - const entries = fs.readdirSync(workspaceDir, {withFileTypes: true}) + const entries = fs + .readdirSync(workspaceDir, {withFileTypes: true}) + .filter(entry => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)) + for (const entry of entries) { - if (entry.isDirectory() && !entry.name.startsWith('.')) { - const isTheAindex = entry.name === aindexName - const projectConfig = this.loadProjectConfig(entry.name, aindexDir, options.aindex.app.src, fs, path, logger) - - aindexProjects.push({ - name: entry.name, - ...isTheAindex && {isPromptSourceProject: true}, - ...projectConfig != null && {projectConfig}, - dirFromWorkspacePath: { - pathKind: FilePathKind.Relative, - path: entry.name, - basePath: workspaceDir, - getDirectoryName: () => entry.name, - getAbsolutePath: () => path.resolve(workspaceDir, entry.name) - } - }) - } + if (entry.name.startsWith('.')) continue + + const isTheAindex = entry.name === aindexName + const projectConfig = this.loadFallbackProjectConfig(entry.name, aindexDir, ctx) + + aindexProjects.push({ + name: entry.name, + ...isTheAindex && {isPromptSourceProject: true}, + ...projectConfig != null && {projectConfig}, + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: entry.name, + basePath: workspaceDir, + getDirectoryName: () => entry.name, + getAbsolutePath: () => path.resolve(workspaceDir, entry.name) + } + }) } } catch (e) { diff --git a/cli/src/inputs/input-project-prompt.test.ts b/cli/src/inputs/input-project-prompt.test.ts index 618e42c7..0c2e9995 100644 --- a/cli/src/inputs/input-project-prompt.test.ts +++ b/cli/src/inputs/input-project-prompt.test.ts @@ -131,4 +131,38 @@ describe('project prompt input plugin workspace prompt support', () => { fs.rmSync(tempWorkspace, {recursive: true, force: true}) } }) + + it('loads ext and arch project prompts using the same agt.mdx workflow as app', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-project-prompt-series-')) + const extRoot = path.join(tempWorkspace, 'aindex', 'dist', 'ext', 'plugin-a') + const archRoot = path.join(tempWorkspace, 'aindex', 'dist', 'arch', 'system-a') + + try { + fs.mkdirSync(path.join(extRoot, 'docs'), {recursive: true}) + fs.mkdirSync(path.join(archRoot, 'design'), {recursive: true}) + fs.writeFileSync(path.join(extRoot, 'agt.mdx'), 'Ext root prompt', 'utf8') + fs.writeFileSync(path.join(extRoot, 'docs', 'agt.mdx'), 'Ext child prompt', 'utf8') + fs.writeFileSync(path.join(archRoot, 'agt.mdx'), 'Arch root prompt', 'utf8') + fs.writeFileSync(path.join(archRoot, 'design', 'agt.mdx'), 'Arch child prompt', 'utf8') + + const workspace = createWorkspace(tempWorkspace, [ + createProject(tempWorkspace, 'plugin-a', {promptSeries: 'ext'}), + createProject(tempWorkspace, 'system-a', {promptSeries: 'arch'}) + ]) + + const plugin = new ProjectPromptInputCapability() + const result = await plugin.collect(createContext(tempWorkspace, workspace)) + const projects = result.workspace?.projects ?? [] + const extProject = projects.find(project => project.name === 'plugin-a') + const archProject = projects.find(project => project.name === 'system-a') + + expect(extProject?.rootMemoryPrompt?.content).toContain('Ext root prompt') + expect(extProject?.childMemoryPrompts?.[0]?.content).toContain('Ext child prompt') + expect(archProject?.rootMemoryPrompt?.content).toContain('Arch root prompt') + expect(archProject?.childMemoryPrompts?.[0]?.content).toContain('Arch child prompt') + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + } + }) }) diff --git a/cli/src/inputs/input-project-prompt.ts b/cli/src/inputs/input-project-prompt.ts index 3b9625b7..e5039491 100644 --- a/cli/src/inputs/input-project-prompt.ts +++ b/cli/src/inputs/input-project-prompt.ts @@ -10,6 +10,7 @@ import type { import process from 'node:process' import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors' +import {resolveAindexProjectSeriesConfig, resolveAindexProjectSeriesConfigs} from '@/aindex-project-series' import {getGlobalConfigPath} from '@/ConfigLoader' import { buildConfigDiagnostic, @@ -33,8 +34,6 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { async collect(ctx: InputCapabilityContext): Promise> { const {dependencyContext, fs, userConfigOptions: options, path, globalScope} = ctx const {aindexDir} = this.resolveBasePaths(options) - - const shadowProjectsDir = this.resolveAindexPath(options.aindex.app.dist, aindexDir) const workspacePromptPath = this.resolveAindexPath(options.aindex.workspacePrompt.dist, aindexDir) const dependencyWorkspace = dependencyContext.workspace @@ -50,8 +49,16 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { if (projectName == null) return project if (project.isWorkspaceRootProject === true) return project - const shadowProjectPath = path.join(shadowProjectsDir, projectName) - if (!fs.existsSync(shadowProjectPath) || !fs.statSync(shadowProjectPath).isDirectory()) return project + const seriesConfigs = project.promptSeries != null + ? [resolveAindexProjectSeriesConfig(options, project.promptSeries)] + : resolveAindexProjectSeriesConfigs(options) + const matchingSeries = seriesConfigs.find(series => { + const shadowProjectPath = path.join(aindexDir, series.dist, projectName) + return fs.existsSync(shadowProjectPath) && fs.statSync(shadowProjectPath).isDirectory() + }) + if (matchingSeries == null) return project + + const shadowProjectPath = path.join(aindexDir, matchingSeries.dist, projectName) const targetProjectPath = project.dirFromWorkspacePath?.getAbsolutePath() @@ -62,6 +69,7 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { return { ...project, + ...project.promptSeries == null ? {promptSeries: matchingSeries.name} : {}, ...rootMemoryPrompt != null && {rootMemoryPrompt}, ...childMemoryPrompts.length > 0 && {childMemoryPrompts} } diff --git a/cli/src/inputs/input-readme.test.ts b/cli/src/inputs/input-readme.test.ts new file mode 100644 index 00000000..fc46e3cd --- /dev/null +++ b/cli/src/inputs/input-readme.test.ts @@ -0,0 +1,49 @@ +import type {InputCapabilityContext} from '../plugins/plugin-core' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import glob from 'fast-glob' +import {describe, expect, it, vi} from 'vitest' +import {mergeConfig} from '../config' +import {ReadmeMdInputCapability} from './input-readme' + +function createContext(tempWorkspace: string, logger: InputCapabilityContext['logger']): InputCapabilityContext { + return { + logger, + fs, + path, + glob, + userConfigOptions: mergeConfig({workspaceDir: tempWorkspace}), + dependencyContext: {} + } as InputCapabilityContext +} + +describe('readme input capability project series validation', () => { + it('fails fast when app, ext, and arch reuse the same project name', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-readme-series-conflict-')) + const error = vi.fn() + const logger = { + error, + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn() + } as InputCapabilityContext['logger'] + + try { + fs.mkdirSync(path.join(tempWorkspace, 'aindex', 'app', 'project-a'), {recursive: true}) + fs.mkdirSync(path.join(tempWorkspace, 'aindex', 'ext', 'project-a'), {recursive: true}) + + await expect(new ReadmeMdInputCapability().collect(createContext(tempWorkspace, logger))) + .rejects + .toThrow('Readme project series name conflict') + expect(error).toHaveBeenCalledWith(expect.objectContaining({ + code: 'README_PROJECT_SERIES_NAME_CONFLICT' + })) + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + } + }) +}) diff --git a/cli/src/inputs/input-readme.ts b/cli/src/inputs/input-readme.ts index 1e7cfe2a..82c60e69 100644 --- a/cli/src/inputs/input-readme.ts +++ b/cli/src/inputs/input-readme.ts @@ -3,6 +3,10 @@ import type {InputCapabilityContext, InputCollectedContext, ReadmeFileKind, Read import process from 'node:process' import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors' +import { + collectAindexProjectSeriesProjectNameConflicts, + resolveAindexProjectSeriesConfigs +} from '@/aindex-project-series' import {getGlobalConfigPath} from '@/ConfigLoader' import { buildConfigDiagnostic, @@ -25,46 +29,100 @@ export class ReadmeMdInputCapability extends AbstractInputCapability { async collect(ctx: InputCapabilityContext): Promise> { const {userConfigOptions: options, logger, fs, path, globalScope} = ctx const {workspaceDir, aindexDir} = this.resolveBasePaths(options) - - const aindexProjectsDir = this.resolveAindexPath(options.aindex.app.dist, aindexDir) - const readmePrompts: ReadmePrompt[] = [] + const projectSeries = resolveAindexProjectSeriesConfigs(options) + const projectRefs = projectSeries.flatMap(series => { + const seriesSourceDir = this.resolveAindexPath(series.src, aindexDir) + if (!(fs.existsSync(seriesSourceDir) && fs.statSync(seriesSourceDir).isDirectory())) return [] - if (!fs.existsSync(aindexProjectsDir) || !fs.statSync(aindexProjectsDir).isDirectory()) { - logger.debug('aindex projects directory does not exist', {path: aindexProjectsDir}) - return {readmePrompts} + return fs + .readdirSync(seriesSourceDir, {withFileTypes: true}) + .filter(entry => entry.isDirectory()) + .map(entry => ({ + projectName: entry.name, + seriesName: series.name, + seriesDir: path.join(seriesSourceDir, entry.name) + })) + }) + const conflicts = collectAindexProjectSeriesProjectNameConflicts(projectRefs) + if (conflicts.length > 0) { + logger.error(buildConfigDiagnostic({ + code: 'README_PROJECT_SERIES_NAME_CONFLICT', + title: 'Readme project names must be unique across app, ext, and arch', + reason: diagnosticLines( + 'Readme-family outputs target bare workspace project directories, so app/ext/arch cannot reuse the same project directory name.', + `Conflicting project names: ${conflicts.map(conflict => conflict.projectName).join(', ')}` + ), + exactFix: diagnosticLines( + 'Rename the conflicting project directory in one of the app/ext/arch source trees and rerun tnmsc.' + ), + possibleFixes: conflicts.map(conflict => diagnosticLines( + `"${conflict.projectName}" is currently declared in: ${conflict.refs.map(ref => `${ref.seriesName} (${ref.seriesDir})`).join(', ')}` + )), + details: { + aindexDir, + conflicts: conflicts.map(conflict => ({ + projectName: conflict.projectName, + refs: conflict.refs.map(ref => ({ + seriesName: ref.seriesName, + seriesDir: ref.seriesDir + })) + })) + } + })) + + throw new Error('Readme project series name conflict') } - try { - const projectEntries = fs.readdirSync(aindexProjectsDir, {withFileTypes: true}) + await Promise.all(projectSeries.map(async series => { + const aindexProjectsDir = this.resolveAindexPath(series.dist, aindexDir) + if (!(fs.existsSync(aindexProjectsDir) && fs.statSync(aindexProjectsDir).isDirectory())) { + logger.debug('aindex project series directory does not exist', {path: aindexProjectsDir, series: series.name}) + return + } - for (const projectEntry of projectEntries) { - if (!projectEntry.isDirectory()) continue + try { + const projectEntries = fs + .readdirSync(aindexProjectsDir, {withFileTypes: true}) + .filter(entry => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)) - const projectName = projectEntry.name - const projectDir = path.join(aindexProjectsDir, projectName) + for (const projectEntry of projectEntries) { + const projectName = projectEntry.name + const projectDir = path.join(aindexProjectsDir, projectName) - await this.collectReadmeFiles( - ctx, - projectDir, - projectName, - workspaceDir, - '', - readmePrompts, - globalScope - ) + await this.collectReadmeFiles( + ctx, + projectDir, + projectName, + workspaceDir, + '', + readmePrompts, + globalScope + ) + } } - } - catch (e) { - logger.error(buildFileOperationDiagnostic({ - code: 'README_PROJECT_SCAN_FAILED', - title: 'Failed to scan aindex projects for readme prompts', - operation: 'scan', - targetKind: 'aindex project directory', - path: aindexProjectsDir, - error: e - })) - } + catch (e) { + logger.error(buildFileOperationDiagnostic({ + code: 'README_PROJECT_SCAN_FAILED', + title: `Failed to scan aindex ${series.name} projects for readme prompts`, + operation: 'scan', + targetKind: `aindex ${series.name} project directory`, + path: aindexProjectsDir, + error: e + })) + } + })) + + readmePrompts.sort((a, b) => { + const projectDiff = a.projectName.localeCompare(b.projectName) + if (projectDiff !== 0) return projectDiff + + const targetDiff = a.targetDir.path.localeCompare(b.targetDir.path) + if (targetDiff !== 0) return targetDiff + + return a.fileKind.localeCompare(b.fileKind) + }) return {readmePrompts} } @@ -137,6 +195,9 @@ export class ReadmeMdInputCapability extends AbstractInputCapability { throw e } + // Readme-family outputs intentionally land in /. + // Cross-series duplicate project names are rejected earlier to keep this + // workspace mapping deterministic and overwrite-free. const targetPath = isRoot ? projectName : path.join(projectName, relativePath) const targetDir: RelativePath = { diff --git a/cli/src/pipeline/ContextMerger.ts b/cli/src/pipeline/ContextMerger.ts index bb91294e..cf7dbd97 100644 --- a/cli/src/pipeline/ContextMerger.ts +++ b/cli/src/pipeline/ContextMerger.ts @@ -3,7 +3,7 @@ * Handles merging of partial InputCollectedContext objects */ -import type {InputCollectedContext, Workspace} from '../plugins/plugin-core' +import type {InputCollectedContext, Project, Workspace} from '../plugins/plugin-core' /** * Merge strategy types for context fields @@ -100,14 +100,21 @@ function mergeArrays( /** * Merge workspace projects. Later projects with the same name replace earlier ones. */ +function buildProjectMergeKey(project: Project): string { + if (project.isWorkspaceRootProject === true) return `workspace-root:${project.name ?? ''}` + + const promptSeries = project.promptSeries ?? 'workspace' + return `${promptSeries}:${project.name ?? ''}` +} + function mergeWorkspaceProjects( base: Workspace, addition: Workspace ): Workspace { const projectMap = new Map() - for (const project of base.projects) projectMap.set(project.name, project) + for (const project of base.projects) projectMap.set(buildProjectMergeKey(project), project) for (const project of addition.projects) - { projectMap.set(project.name, project) } + { projectMap.set(buildProjectMergeKey(project), project) } return { directory: addition.directory ?? base.directory, projects: [...projectMap.values()] diff --git a/cli/src/plugins/plugin-core/AindexTypes.ts b/cli/src/plugins/plugin-core/AindexTypes.ts index aaa44f1b..3373a685 100644 --- a/cli/src/plugins/plugin-core/AindexTypes.ts +++ b/cli/src/plugins/plugin-core/AindexTypes.ts @@ -51,11 +51,15 @@ export interface AindexDirectory { readonly agents: AindexDirectoryEntry readonly rules: AindexDirectoryEntry readonly app: AindexDirectoryEntry + readonly ext: AindexDirectoryEntry + readonly arch: AindexDirectoryEntry readonly globalMemoryFile: AindexFileEntry readonly workspaceMemoryFile: AindexFileEntry } /** App directory (project-specific prompts source, standalone at root) */ readonly app: AindexDirectoryEntry + readonly ext: AindexDirectoryEntry + readonly arch: AindexDirectoryEntry /** IDE configuration directories */ readonly ide: { readonly idea: AindexDirectoryEntry @@ -69,6 +73,10 @@ export interface AindexDirectory { readonly ignoreFiles: readonly AindexFileEntry[] } +export const AINDEX_PROJECT_SERIES_NAMES = ['app', 'ext', 'arch'] as const + +export type AindexProjectSeriesName = (typeof AINDEX_PROJECT_SERIES_NAMES)[number] + /** * Directory names used in aindex project */ @@ -80,6 +88,8 @@ export const AINDEX_DIR_NAMES = { AGENTS: 'agents', RULES: 'rules', APP: 'app', + EXT: 'ext', + ARCH: 'arch', IDEA: '.idea', // IDE directories IDEA_CODE_STYLES: '.idea/codeStyles', VSCODE: '.vscode', @@ -116,16 +126,20 @@ export const AINDEX_RELATIVE_PATHS = { SRC_COMMANDS: 'src/commands', SRC_AGENTS: 'src/agents', SRC_RULES: 'src/rules', - SRC_GLOBAL_MEMORY: 'app/global.src.mdx', - SRC_WORKSPACE_MEMORY: 'app/workspace.src.mdx', + SRC_GLOBAL_MEMORY: 'global.src.mdx', + SRC_WORKSPACE_MEMORY: 'workspace.src.mdx', DIST_SKILLS: 'dist/skills', // Distribution paths DIST_COMMANDS: 'dist/commands', DIST_AGENTS: 'dist/agents', DIST_RULES: 'dist/rules', DIST_APP: 'dist/app', + DIST_EXT: 'dist/ext', + DIST_ARCH: 'dist/arch', DIST_GLOBAL_MEMORY: 'dist/global.mdx', DIST_WORKSPACE_MEMORY: 'dist/workspace.mdx', - APP: 'app' // App source path (standalone at root) + APP: 'app', // App source path (standalone at root) + EXT: 'ext', + ARCH: 'arch' } as const /** @@ -200,6 +214,16 @@ export const DEFAULT_AINDEX_STRUCTURE: AindexDirectory = { name: AINDEX_DIR_NAMES.APP, required: false, description: 'Compiled project-specific prompts' + }, + ext: { + name: AINDEX_DIR_NAMES.EXT, + required: false, + description: 'Compiled extension-specific prompts' + }, + arch: { + name: AINDEX_DIR_NAMES.ARCH, + required: false, + description: 'Compiled architecture-specific prompts' } }, app: { @@ -207,6 +231,16 @@ export const DEFAULT_AINDEX_STRUCTURE: AindexDirectory = { required: false, description: 'Project-specific prompts (standalone directory)' }, + ext: { + name: AINDEX_DIR_NAMES.EXT, + required: false, + description: 'Extension-specific prompts (standalone directory)' + }, + arch: { + name: AINDEX_DIR_NAMES.ARCH, + required: false, + description: 'Architecture-specific prompts (standalone directory)' + }, ide: { idea: { name: AINDEX_DIR_NAMES.IDEA, diff --git a/cli/src/plugins/plugin-core/InputTypes.ts b/cli/src/plugins/plugin-core/InputTypes.ts index 674d9afd..e3785c2b 100644 --- a/cli/src/plugins/plugin-core/InputTypes.ts +++ b/cli/src/plugins/plugin-core/InputTypes.ts @@ -1,3 +1,4 @@ +import type {AindexProjectSeriesName} from './AindexTypes' import type {ProjectConfig} from './ConfigTypes.schema' import type {FilePathKind, IDEKind, PromptKind, RuleScope} from './enums' import type { @@ -23,6 +24,7 @@ export interface Project { readonly isPromptSourceProject?: boolean readonly isWorkspaceRootProject?: boolean readonly projectConfig?: ProjectConfig + readonly promptSeries?: AindexProjectSeriesName } export interface Workspace { diff --git a/cli/src/prompts.test.ts b/cli/src/prompts.test.ts index 3678aa45..51346f48 100644 --- a/cli/src/prompts.test.ts +++ b/cli/src/prompts.test.ts @@ -44,12 +44,12 @@ describe('prompt catalog service', () => { const now = Date.now() writeFile( - path.join(aindexDir, 'app', 'global.src.mdx'), + path.join(aindexDir, 'global.src.mdx'), '---\ndescription: global zh\n---\nGlobal zh', new Date(now) ) writeFile( - path.join(aindexDir, 'app', 'global.mdx'), + path.join(aindexDir, 'global.mdx'), '---\ndescription: global en\n---\nGlobal en', new Date(now - 10_000) ) @@ -60,12 +60,12 @@ describe('prompt catalog service', () => { ) writeFile( - path.join(aindexDir, 'app', 'workspace.src.mdx'), + path.join(aindexDir, 'workspace.src.mdx'), '---\ndescription: workspace zh\n---\nWorkspace zh', new Date(now) ) writeFile( - path.join(aindexDir, 'app', 'workspace.mdx'), + path.join(aindexDir, 'workspace.mdx'), '---\ndescription: workspace en\n---\nWorkspace en', new Date(now + 1_000) ) @@ -102,6 +102,28 @@ describe('prompt catalog service', () => { new Date(now + 1_000) ) + writeFile( + path.join(aindexDir, 'ext', 'project-a', 'agt.src.mdx'), + '---\ndescription: ext project zh\n---\nExt project zh', + new Date(now) + ) + writeFile( + path.join(aindexDir, 'dist', 'ext', 'project-a', 'agt.mdx'), + '---\ndescription: ext project dist\n---\nExt project dist', + new Date(now + 1_000) + ) + + writeFile( + path.join(aindexDir, 'arch', 'system-a', 'agt.src.mdx'), + '---\ndescription: arch project zh\n---\nArch project zh', + new Date(now) + ) + writeFile( + path.join(aindexDir, 'dist', 'arch', 'system-a', 'agt.mdx'), + '---\ndescription: arch project dist\n---\nArch project dist', + new Date(now + 1_000) + ) + writeFile( path.join(aindexDir, 'skills', 'reviewer', 'skill.src.mdx'), '---\ndescription: skill zh\n---\nSkill zh', @@ -168,33 +190,43 @@ describe('prompt catalog service', () => { const prompts = await listPrompts(serviceOptions(workspaceDir)) - expect(prompts.map(prompt => prompt.kind)).toEqual([ - 'command', + expect(prompts.map(prompt => prompt.promptId)).toEqual([ + 'command:dev/build', 'global-memory', - 'project-child-memory', - 'project-memory', - 'rule', - 'skill-child-doc', - 'skill', - 'subagent', + 'project-child-memory:app/project-b/docs', + 'project-memory:app/project-a', + 'project-memory:arch/system-a', + 'project-memory:ext/project-a', + 'rule:frontend', + 'skill-child-doc:reviewer/guide', + 'skill:reviewer', + 'subagent:qa/boot', 'workspace-memory' ]) expect(prompts.find(prompt => prompt.promptId === 'global-memory')).toEqual(expect.objectContaining({enStatus: 'stale', distStatus: 'stale'})) expect(prompts.find(prompt => prompt.promptId === 'workspace-memory')).toEqual(expect.objectContaining({enStatus: 'ready', distStatus: 'ready'})) - expect(prompts.find(prompt => prompt.promptId === 'project-child-memory:project-b/docs')).toEqual(expect.objectContaining({ + expect(prompts.find(prompt => prompt.promptId === 'project-child-memory:app/project-b/docs')).toEqual(expect.objectContaining({ legacyZhSource: true, enStatus: 'missing', distStatus: 'ready' })) + expect(prompts.find(prompt => prompt.promptId === 'project-memory:ext/project-a')).toEqual(expect.objectContaining({ + logicalName: 'ext/project-a', + distStatus: 'ready' + })) expect(prompts.find(prompt => prompt.promptId === 'command:dev/build')).toEqual(expect.objectContaining({enStatus: 'missing', distStatus: 'ready'})) const filtered = await listPrompts({ ...serviceOptions(workspaceDir), - kinds: ['command'], + kinds: ['project-memory'], distStatus: ['ready'] }) - expect(filtered.map(prompt => prompt.promptId)).toEqual(['command:dev/build']) + expect(filtered.map(prompt => prompt.promptId)).toEqual([ + 'project-memory:app/project-a', + 'project-memory:arch/system-a', + 'project-memory:ext/project-a' + ]) }) it('returns prompt contents and expected paths', async () => { @@ -251,6 +283,7 @@ describe('prompt catalog service', () => { expect(fs.readFileSync(path.join(aindexDir, 'app', 'project-c', 'agt.src.mdx'), 'utf8')).toContain('Legacy zh') expect(fs.readFileSync(legacyPath, 'utf8')).toContain('Translated en') + expect(migrated.promptId).toBe('project-memory:app/project-c') expect(migrated.src.zh?.legacySource).toBeUndefined() expect(migrated.src.en?.content).toContain('Translated en') @@ -266,6 +299,30 @@ describe('prompt catalog service', () => { expect(rewritten.exists.en).toBe(false) }) + it('accepts legacy app project IDs while resolving to series-aware paths', async () => { + const workspaceDir = createTempWorkspace('tnmsc-project-legacy-id-') + const aindexDir = path.join(workspaceDir, 'aindex') + const modifiedAt = new Date() + + writeFile( + path.join(aindexDir, 'app', 'project-a', 'agt.src.mdx'), + '---\ndescription: project zh\n---\nProject zh', + modifiedAt + ) + writeFile( + path.join(aindexDir, 'dist', 'app', 'project-a', 'agt.mdx'), + '---\ndescription: project dist\n---\nProject dist', + modifiedAt + ) + + const prompt = await getPrompt('project-memory:project-a', serviceOptions(workspaceDir)) + const resolvedPaths = await resolvePromptDefinition('project-memory:project-a', serviceOptions(workspaceDir)) + + expect(prompt?.promptId).toBe('project-memory:app/project-a') + expect(resolvedPaths.zh).toBe(path.join(aindexDir, 'app', 'project-a', 'agt.src.mdx')) + expect(resolvedPaths.dist).toBe(path.join(aindexDir, 'dist', 'app', 'project-a', 'agt.mdx')) + }) + it('writes translation artifacts independently for en and dist', async () => { const workspaceDir = createTempWorkspace('tnmsc-translation-write-') const aindexDir = path.join(workspaceDir, 'aindex') diff --git a/cli/src/prompts.ts b/cli/src/prompts.ts index 554b80b2..b04dd9b4 100644 --- a/cli/src/prompts.ts +++ b/cli/src/prompts.ts @@ -1,8 +1,13 @@ -import type {PluginOptions, YAMLFrontMatter} from '@/plugins/plugin-core' +import type {AindexProjectSeriesName, PluginOptions, YAMLFrontMatter} from '@/plugins/plugin-core' import * as fs from 'node:fs' import * as path from 'node:path' import {parseMarkdown} from '@truenine/md-compiler/markdown' import glob from 'fast-glob' +import { + isAindexProjectSeriesName, + resolveAindexProjectSeriesConfig, + resolveAindexProjectSeriesConfigs +} from '@/aindex-project-series' import {mergeConfig, userConfigToPluginOptions} from './config' import {getConfigLoader} from './ConfigLoader' import {PathPlaceholders} from './plugins/plugin-core' @@ -107,6 +112,7 @@ interface PromptDefinition { interface PromptIdDescriptor { readonly kind: ManagedPromptKind + readonly seriesName?: AindexProjectSeriesName readonly projectName?: string readonly relativeName?: string readonly skillName?: string @@ -227,6 +233,7 @@ function buildWorkspaceMemoryDefinition(env: ResolvedPromptEnvironment): PromptD function buildProjectMemoryDefinition( env: ResolvedPromptEnvironment, + seriesName: AindexProjectSeriesName, projectName: string, relativeName?: string ): PromptDefinition { @@ -236,20 +243,21 @@ function buildProjectMemoryDefinition( const normalizedRelativeName = relativeName == null ? '' : normalizeRelativeIdentifier(relativeName, 'relativeName') + const seriesConfig = resolveAindexProjectSeriesConfig(env.options, seriesName) const sourceDir = normalizedRelativeName.length === 0 - ? path.join(env.aindexDir, env.options.aindex.app.src, normalizedProjectName) - : path.join(env.aindexDir, env.options.aindex.app.src, normalizedProjectName, normalizedRelativeName) + ? path.join(env.aindexDir, seriesConfig.src, normalizedProjectName) + : path.join(env.aindexDir, seriesConfig.src, normalizedProjectName, normalizedRelativeName) const distDir = normalizedRelativeName.length === 0 - ? path.join(env.aindexDir, env.options.aindex.app.dist, normalizedProjectName) - : path.join(env.aindexDir, env.options.aindex.app.dist, normalizedProjectName, normalizedRelativeName) + ? path.join(env.aindexDir, seriesConfig.dist, normalizedProjectName) + : path.join(env.aindexDir, seriesConfig.dist, normalizedProjectName, normalizedRelativeName) const legacyPath = path.join(sourceDir, `${PROJECT_MEMORY_FILE_NAME}${MDX_EXTENSION}`) const logicalSuffix = normalizedRelativeName.length === 0 - ? normalizedProjectName - : `${normalizedProjectName}/${normalizedRelativeName}` + ? `${seriesName}/${normalizedProjectName}` + : `${seriesName}/${normalizedProjectName}/${normalizedRelativeName}` return { promptId: normalizedRelativeName.length === 0 - ? `project-memory:${normalizedProjectName}` + ? `project-memory:${logicalSuffix}` : `project-child-memory:${logicalSuffix}`, kind: normalizedRelativeName.length === 0 ? 'project-memory' : 'project-child-memory', logicalName: logicalSuffix, @@ -353,13 +361,9 @@ function parsePromptId(promptId: string): PromptIdDescriptor { switch (kind) { case 'project-memory': - if (!isSingleSegmentIdentifier(normalizedValue)) throw new Error('project-memory promptId must include a single project name') - return {kind, projectName: normalizedValue} + return parseProjectPromptDescriptor(kind, normalizedValue) case 'project-child-memory': { - const [projectName, ...rest] = normalizedValue.split('/') - const relativeName = rest.join('/') - if (projectName == null || relativeName.length === 0) throw new Error('project-child-memory promptId must include project and child path') - return {kind, projectName, relativeName} + return parseProjectPromptDescriptor(kind, normalizedValue) } case 'skill': if (!isSingleSegmentIdentifier(normalizedValue)) throw new Error('skill promptId must include a single skill name') @@ -377,6 +381,38 @@ function parsePromptId(promptId: string): PromptIdDescriptor { } } +function parseProjectPromptDescriptor( + kind: Extract, + normalizedValue: string +): PromptIdDescriptor { + const segments = normalizedValue.split('/') + const maybeSeriesName = segments[0] + const hasSeriesName = maybeSeriesName != null && isAindexProjectSeriesName(maybeSeriesName) + + if (kind === 'project-memory') { + if (hasSeriesName) { + const projectName = segments[1] + if (projectName == null || segments.length !== 2) throw new Error('project-memory promptId must include exactly one project name after the series') + return {kind, seriesName: maybeSeriesName, projectName} + } + + if (!isSingleSegmentIdentifier(normalizedValue)) throw new Error('project-memory promptId must include a single project name') + return {kind, seriesName: 'app', projectName: normalizedValue} + } + + if (hasSeriesName) { + const projectName = segments[1] + const relativeName = segments.slice(2).join('/') + if (projectName == null || relativeName.length === 0) throw new Error('project-child-memory promptId must include series, project, and child path') + return {kind, seriesName: maybeSeriesName, projectName, relativeName} + } + + const [projectName, ...rest] = segments + const relativeName = rest.join('/') + if (projectName == null || relativeName.length === 0) throw new Error('project-child-memory promptId must include project and child path') + return {kind, seriesName: 'app', projectName, relativeName} +} + function buildPromptDefinitionFromId( promptId: string, env: ResolvedPromptEnvironment @@ -388,12 +424,12 @@ function buildPromptDefinitionFromId( case 'workspace-memory': return buildWorkspaceMemoryDefinition(env) case 'project-memory': if (descriptor.projectName == null) throw new Error('project-memory promptId must include a project name') - return buildProjectMemoryDefinition(env, descriptor.projectName) + return buildProjectMemoryDefinition(env, descriptor.seriesName ?? 'app', descriptor.projectName) case 'project-child-memory': if (descriptor.projectName == null || descriptor.relativeName == null) { throw new Error('project-child-memory promptId must include project and child path') } - return buildProjectMemoryDefinition(env, descriptor.projectName, descriptor.relativeName) + return buildProjectMemoryDefinition(env, descriptor.seriesName ?? 'app', descriptor.projectName, descriptor.relativeName) case 'skill': if (descriptor.skillName == null) throw new Error('skill promptId must include a skill name') return buildSkillDefinition(env, descriptor.skillName) @@ -478,30 +514,32 @@ function collectSkillPromptIds(env: ResolvedPromptEnvironment): string[] { } function collectProjectPromptIds(env: ResolvedPromptEnvironment): string[] { - const sourceRoot = path.join(env.aindexDir, env.options.aindex.app.src) - const distRoot = path.join(env.aindexDir, env.options.aindex.app.dist) - const relativeDirs = new Set() + const promptIds: string[] = [] - for (const match of listFiles(sourceRoot, [`**/${PROJECT_MEMORY_FILE_NAME}${SOURCE_PROMPT_EXTENSION}`, `**/${PROJECT_MEMORY_FILE_NAME}${MDX_EXTENSION}`])) { - const directory = normalizeSlashPath(path.posix.dirname(normalizeSlashPath(match))) - if (directory !== '.') relativeDirs.add(directory) - } + for (const series of resolveAindexProjectSeriesConfigs(env.options)) { + const sourceRoot = path.join(env.aindexDir, series.src) + const distRoot = path.join(env.aindexDir, series.dist) + const relativeDirs = new Set() - for (const match of listFiles(distRoot, [`**/${PROJECT_MEMORY_FILE_NAME}${MDX_EXTENSION}`])) { - const directory = normalizeSlashPath(path.posix.dirname(normalizeSlashPath(match))) - if (directory !== '.') relativeDirs.add(directory) - } + for (const match of listFiles(sourceRoot, [`**/${PROJECT_MEMORY_FILE_NAME}${SOURCE_PROMPT_EXTENSION}`, `**/${PROJECT_MEMORY_FILE_NAME}${MDX_EXTENSION}`])) { + const directory = normalizeSlashPath(path.posix.dirname(normalizeSlashPath(match))) + if (directory !== '.') relativeDirs.add(directory) + } - const promptIds: string[] = [] + for (const match of listFiles(distRoot, [`**/${PROJECT_MEMORY_FILE_NAME}${MDX_EXTENSION}`])) { + const directory = normalizeSlashPath(path.posix.dirname(normalizeSlashPath(match))) + if (directory !== '.') relativeDirs.add(directory) + } - for (const relativeDir of [...relativeDirs].sort()) { - const [projectName, ...rest] = relativeDir.split('/') - const childPath = rest.join('/') - if (projectName == null || projectName.length === 0) continue + for (const relativeDir of [...relativeDirs].sort()) { + const [projectName, ...rest] = relativeDir.split('/') + const childPath = rest.join('/') + if (projectName == null || projectName.length === 0) continue - promptIds.push(childPath.length === 0 - ? `project-memory:${projectName}` - : `project-child-memory:${projectName}/${childPath}`) + promptIds.push(childPath.length === 0 + ? `project-memory:${series.name}/${projectName}` + : `project-child-memory:${series.name}/${projectName}/${childPath}`) + } } return promptIds diff --git a/doc/content/technical-details/global-and-workspace-prompts.mdx b/doc/content/technical-details/global-and-workspace-prompts.mdx index 06129b99..d98bacfb 100644 --- a/doc/content/technical-details/global-and-workspace-prompts.mdx +++ b/doc/content/technical-details/global-and-workspace-prompts.mdx @@ -12,8 +12,8 @@ status: stable 当前默认配置中,这两份源文件分别是: ```text -app/global.src.mdx -app/workspace.src.mdx +global.src.mdx +workspace.src.mdx ``` 输出目标默认是: diff --git a/doc/package.json b/doc/package.json index 8dbb761d..df363d35 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10327.10010", + "version": "2026.10328.106", "private": true, "description": "Chinese-first manifesto-led documentation site for @truenine/memory-sync.", "engines": { diff --git a/gui/package.json b/gui/package.json index 40e01e87..5fbaf66b 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10327.10010", + "version": "2026.10328.106", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index b856ec7f..50bfc26b 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -1774,7 +1774,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10327.10010" +version = "2026.10328.106" dependencies = [ "dirs", "json5", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 8af6183b..6f208f45 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10327.10010" +version = "2026.10328.106" description = "Memory Sync desktop GUI application" authors.workspace = true edition.workspace = true diff --git a/gui/src-tauri/src/commands.rs b/gui/src-tauri/src/commands.rs index 3ab1eb4f..19e4c9e1 100644 --- a/gui/src-tauri/src/commands.rs +++ b/gui/src-tauri/src/commands.rs @@ -20,6 +20,17 @@ const DEFAULT_SUB_AGENTS_SRC_DIR: &str = "subagents"; const DEFAULT_SUB_AGENTS_DIST_DIR: &str = "dist/subagents"; const DEFAULT_RULES_SRC_DIR: &str = "rules"; const DEFAULT_RULES_DIST_DIR: &str = "dist/rules"; +const DEFAULT_APP_SRC_DIR: &str = "app"; +const DEFAULT_APP_DIST_DIR: &str = "dist/app"; +const DEFAULT_EXT_SRC_DIR: &str = "ext"; +const DEFAULT_EXT_DIST_DIR: &str = "dist/ext"; +const DEFAULT_ARCH_SRC_DIR: &str = "arch"; +const DEFAULT_ARCH_DIST_DIR: &str = "dist/arch"; +const DEFAULT_GLOBAL_PROMPT_SRC: &str = "global.src.mdx"; +const DEFAULT_GLOBAL_PROMPT_DIST: &str = "dist/global.mdx"; +const DEFAULT_WORKSPACE_PROMPT_SRC: &str = "workspace.src.mdx"; +const DEFAULT_WORKSPACE_PROMPT_DIST: &str = "dist/workspace.mdx"; +const PROJECT_SERIES_CATEGORIES: [&str; 3] = ["app", "ext", "arch"]; fn has_source_mdx_extension(name: &str) -> bool { name.ends_with(PRIMARY_SOURCE_MDX_EXTENSION) @@ -286,10 +297,129 @@ fn resolve_category_paths( DEFAULT_RULES_SRC_DIR, DEFAULT_RULES_DIST_DIR, )), + "app" => Ok(resolve_pair( + aindex.and_then(|value| value.app.as_ref()), + DEFAULT_APP_SRC_DIR, + DEFAULT_APP_DIST_DIR, + )), + "ext" => Ok(resolve_pair( + aindex.and_then(|value| value.ext.as_ref()), + DEFAULT_EXT_SRC_DIR, + DEFAULT_EXT_DIST_DIR, + )), + "arch" => Ok(resolve_pair( + aindex.and_then(|value| value.arch.as_ref()), + DEFAULT_ARCH_SRC_DIR, + DEFAULT_ARCH_DIST_DIR, + )), _ => Err(format!("Unknown category: {category}")), } } +fn collect_project_series_category_files( + src_dir: &std::path::Path, + base: &std::path::Path, + translated_root_rel: &str, + dist_dir: &std::path::Path, + out: &mut Vec, +) -> std::io::Result<()> { + if let Ok(top_entries) = std::fs::read_dir(src_dir) { + for top in top_entries.flatten() { + if top.path().is_dir() { + collect_category_source_mdx( + &top.path(), + src_dir, + base, + translated_root_rel, + dist_dir, + out, + )?; + } + } + } + + Ok(()) +} + +fn collect_root_memory_prompt_files( + base: &std::path::Path, + config: &tnmsc::core::config::UserConfigFile, + out: &mut Vec, +) { + for (source_rel, translated_rel) in collect_root_memory_prompt_pairs(config) { + let source_abs = base.join(&source_rel); + if !(source_abs.exists() && source_abs.is_file()) { + continue; + } + + out.push(AindexFileEntry { + source_path: source_rel, + translated_path: translated_rel.clone(), + translated_exists: base.join(translated_rel).exists(), + file_type: SOURCE_MDX_FILE_TYPE.to_string(), + }); + } +} + +fn collect_root_memory_prompt_pairs( + config: &tnmsc::core::config::UserConfigFile, +) -> Vec<(String, String)> { + let aindex = config.aindex.as_ref(); + [ + ( + aindex.and_then(|value| value.global_prompt.as_ref()), + DEFAULT_GLOBAL_PROMPT_SRC, + DEFAULT_GLOBAL_PROMPT_DIST, + ), + ( + aindex.and_then(|value| value.workspace_prompt.as_ref()), + DEFAULT_WORKSPACE_PROMPT_SRC, + DEFAULT_WORKSPACE_PROMPT_DIST, + ), + ] + .into_iter() + .map(|(pair, default_source, default_dist)| { + let source_rel = pair + .and_then(|value| value.src.as_deref()) + .unwrap_or(default_source) + .replace('\\', "/"); + let translated_rel = pair + .and_then(|value| value.dist.as_deref()) + .unwrap_or(default_dist) + .replace('\\', "/"); + (source_rel, translated_rel) + }) + .collect() +} + +fn collect_category_file_entries( + base: &std::path::Path, + config: &tnmsc::core::config::UserConfigFile, + category: &str, +) -> Result, String> { + let paths = resolve_category_paths(config, category)?; + let dist_dir = base.join(&paths.translated_rel); + let src_dir = base.join(&paths.source_rel); + let mut entries = Vec::new(); + + if category == "app" { + collect_root_memory_prompt_files(base, config, &mut entries); + } + if src_dir.exists() { + collect_project_series_category_files( + &src_dir, + base, + &paths.translated_rel, + &dist_dir, + &mut entries, + ) + .map_err(|e| format!("Failed to scan {}: {e}", category))?; + } + + entries.sort_by(|a, b| a.source_path.cmp(&b.source_path)); + Ok(entries) +} + /// Read and resolve the merged tnmsc config for the current working directory. fn load_resolved_config(cwd: &str) -> Result { let result = @@ -319,63 +449,36 @@ fn resolve_aindex_root(cwd: &str) -> Result { Ok(path) } -/// Recursively collect all source prompt files under `aindex/app/`. +/// Collect project-like source prompt files under `aindex/app/`, `aindex/ext/`, and `aindex/arch/`. #[tauri::command] pub fn list_aindex_files(cwd: String) -> Result, String> { - let base = resolve_aindex_root(&cwd)?; - let app_dir = base.join("app"); - if !app_dir.exists() { - return Ok(vec![]); - } + let ResolvedConfig { + aindex_root: base, + config, + } = load_resolved_config(&cwd)?; let mut entries = Vec::new(); - collect_source_mdx(&app_dir, &base, &mut entries) - .map_err(|e| format!("Failed to scan aindex: {e}"))?; - entries.sort_by(|a, b| a.source_path.cmp(&b.source_path)); - Ok(entries) -} + collect_root_memory_prompt_files(&base, &config, &mut entries); -fn collect_source_mdx( - dir: &std::path::Path, - base: &std::path::Path, - out: &mut Vec, -) -> std::io::Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - collect_source_mdx(&path, base, out)?; - } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if has_source_mdx_extension(name) { - let rel = path.strip_prefix(base).unwrap_or(&path); - let source_path = rel.to_string_lossy().replace('\\', "/"); - // Determine translated path: - // - app/global.src.mdx -> dist/global.mdx (root-level files) - // - app/X/foo.src.mdx -> dist/app/X/foo.mdx (subdirectory files) - let without_ext = replace_source_mdx_extension(&source_path) - .unwrap_or_else(|| source_path.clone()); - let translated_rel = if without_ext.starts_with("app/") { - let after_app = &without_ext["app/".len()..]; - if after_app.contains('/') { - // Subdirectory: keep app/ prefix under dist/ - format!("dist/{without_ext}") - } else { - // Root-level file in app/: goes to dist/ directly - format!("dist/{after_app}") - } - } else { - format!("dist/{without_ext}") - }; - let translated_abs = base.join(&translated_rel); - out.push(AindexFileEntry { - source_path, - translated_path: translated_rel, - translated_exists: translated_abs.exists(), - file_type: SOURCE_MDX_FILE_TYPE.to_string(), - }); - } + for category in PROJECT_SERIES_CATEGORIES { + let paths = resolve_category_paths(&config, category)?; + let src_dir = base.join(&paths.source_rel); + if !src_dir.exists() { + continue; } + + let dist_dir = base.join(&paths.translated_rel); + collect_project_series_category_files( + &src_dir, + &base, + &paths.translated_rel, + &dist_dir, + &mut entries, + ) + .map_err(|e| format!("Failed to scan aindex {category}: {e}"))?; } - Ok(()) + + entries.sort_by(|a, b| a.source_path.cmp(&b.source_path)); + Ok(entries) } /// Read a file relative to the aindex directory (resolved from config). @@ -401,7 +504,7 @@ pub fn write_aindex_file(cwd: String, rel_path: String, content: String) -> Resu std::fs::write(&path, &content).map_err(|e| format!("Failed to write {}: {e}", path.display())) } -/// List source prompt files for a given category (skills, commands, agents). +/// List source prompt files for a given category. /// Reads the corresponding `aindex` config field to resolve source and output directories. #[tauri::command] pub fn list_category_files(cwd: String, category: String) -> Result, String> { @@ -409,33 +512,7 @@ pub fn list_category_files(cwd: String, category: String) -> Result ( - u32, - u64, - u64, - u32, - u32, - u32, - std::collections::HashMap, -) { - let mut file_count = 0u32; - let mut total_chars = 0u64; - let mut total_lines = 0u64; - let mut source_mdx = 0u32; - let mut resource = 0u32; - let mut translated = 0u32; - let mut ext_map: std::collections::HashMap = std::collections::HashMap::new(); +#[derive(Debug, Clone, Default)] +struct StatAccumulator { + file_count: u32, + total_chars: u64, + total_lines: u64, + source_mdx_count: u32, + resource_count: u32, + translated_count: u32, + ext_map: std::collections::HashMap, +} +impl StatAccumulator { + fn add(&mut self, other: Self) { + self.file_count += other.file_count; + self.total_chars += other.total_chars; + self.total_lines += other.total_lines; + self.source_mdx_count += other.source_mdx_count; + self.resource_count += other.resource_count; + self.translated_count += other.translated_count; + for (key, value) in other.ext_map { + *self.ext_map.entry(key).or_default() += value; + } + } + + fn from_file(path: &std::path::Path) -> Self { + let mut stats = Self::default(); + if !path.is_file() { + return stats; + } + + stats.file_count = 1; + if let Ok(content) = std::fs::read_to_string(path) { + stats.total_chars = content.len() as u64; + stats.total_lines = content.lines().count() as u64; + } + + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if has_source_mdx_extension(name) { + stats.source_mdx_count = 1; + stats.ext_map.insert("src.mdx".to_string(), 1); + } else { + let ext = name.rsplit('.').next().unwrap_or("other").to_lowercase(); + stats.ext_map.insert(ext, 1); + } + + stats + } +} + +/// Recursively count files and accumulate chars/lines. +fn stat_dir(dir: &std::path::Path) -> StatAccumulator { + let mut stats = StatAccumulator::default(); if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { - let (fc, tc, tl, cm, rc, tr, em) = stat_dir(&path); - file_count += fc; - total_chars += tc; - total_lines += tl; - source_mdx += cm; - resource += rc; - translated += tr; - for (k, v) in em { - *ext_map.entry(k).or_default() += v; - } + stats.add(stat_dir(&path)); } else if path.is_file() { - file_count += 1; - if let Ok(content) = std::fs::read_to_string(&path) { - total_chars += content.len() as u64; - total_lines += content.lines().count() as u64; - } - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if has_source_mdx_extension(name) { - source_mdx += 1; - *ext_map.entry("src.mdx".to_string()).or_default() += 1; - } else { - // Extract extension - let ext = name.rsplit('.').next().unwrap_or("other").to_lowercase(); - *ext_map.entry(ext).or_default() += 1; - } + stats.add(StatAccumulator::from_file(&path)); } } } - ( - file_count, - total_chars, - total_lines, - source_mdx, - resource, - translated, - ext_map, - ) + stats } -/// Gather comprehensive statistics about the aindex project. -#[tauri::command] -pub fn get_aindex_stats(cwd: String) -> Result { - let ResolvedConfig { - aindex_root: base, - config, - } = load_resolved_config(&cwd)?; - let mut stats = AindexStats::default(); - let mut all_ext: std::collections::HashMap = std::collections::HashMap::new(); +fn derive_english_source_rel(source_rel: &str) -> Option { + replace_source_mdx_extension(source_rel).filter(|derived| derived != source_rel) +} - // Scan app/ for project stats - let app_dir = base.join("app"); - if app_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&app_dir) { +fn collect_root_memory_prompt_stats( + base: &std::path::Path, + config: &tnmsc::core::config::UserConfigFile, +) -> StatAccumulator { + let mut stats = StatAccumulator::default(); + let mut seen_paths = std::collections::HashSet::new(); + + for (source_rel, _) in collect_root_memory_prompt_pairs(config) { + for relative_path in std::iter::once(source_rel.clone()) + .chain(derive_english_source_rel(&source_rel).into_iter()) + { + if !seen_paths.insert(relative_path.clone()) { + continue; + } + + let absolute_path = base.join(&relative_path); + if absolute_path.exists() && absolute_path.is_file() { + stats.add(StatAccumulator::from_file(&absolute_path)); + } + } + } + + stats +} + +fn accumulate_overall_stats( + summary: &StatAccumulator, + stats: &mut AindexStats, + all_ext: &mut std::collections::HashMap, +) { + stats.total_files += summary.file_count; + stats.total_chars += summary.total_chars; + stats.total_lines += summary.total_lines; + stats.total_source_mdx += summary.source_mdx_count; + stats.total_resources += summary.resource_count; + for (key, value) in &summary.ext_map { + *all_ext.entry(key.clone()).or_default() += *value; + } +} + +fn collect_project_series_stats( + base: &std::path::Path, + config: &tnmsc::core::config::UserConfigFile, + stats: &mut AindexStats, + all_ext: &mut std::collections::HashMap, +) -> Result<(), String> { + for series_name in PROJECT_SERIES_CATEGORIES { + let category_paths = resolve_category_paths(config, series_name)?; + let src_dir = base.join(&category_paths.source_rel); + if !src_dir.exists() { + continue; + } + + if let Ok(entries) = std::fs::read_dir(&src_dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { - let name = path + let project_name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_string(); - let (fc, tc, tl, cm, rc, _tr, em) = stat_dir(&path); + let label = if series_name == "app" { + project_name + } else { + format!("{series_name}/{project_name}") + }; + let project_stats = stat_dir(&path); stats.projects.push(ProjectStats { - name, - file_count: fc, - total_chars: tc, - total_lines: tl, + name: label, + file_count: project_stats.file_count, + total_chars: project_stats.total_chars, + total_lines: project_stats.total_lines, }); - stats.total_files += fc; - stats.total_chars += tc; - stats.total_lines += tl; - stats.total_source_mdx += cm; - stats.total_resources += rc; - for (k, v) in em { - *all_ext.entry(k).or_default() += v; - } + accumulate_overall_stats(&project_stats, stats, all_ext); } } } } - // Scan configured source directories for skills, commands, agents - for cat_name in &["skills", "commands", "agents"] { - let category_paths = resolve_category_paths(&config, cat_name)?; + Ok(()) +} + +fn build_aindex_stats( + base: &std::path::Path, + config: &tnmsc::core::config::UserConfigFile, +) -> Result { + let mut stats = AindexStats::default(); + let mut all_ext: std::collections::HashMap = std::collections::HashMap::new(); + let root_prompt_stats = collect_root_memory_prompt_stats(base, config); + + accumulate_overall_stats(&root_prompt_stats, &mut stats, &mut all_ext); + collect_project_series_stats(base, config, &mut stats, &mut all_ext)?; + + // Root global/workspace prompts live outside the project-series directories, + // so the App category needs them merged back in explicitly. + for cat_name in &["app", "ext", "arch", "skills", "commands", "agents"] { + let category_paths = resolve_category_paths(config, cat_name)?; let src_dir = base.join(&category_paths.source_rel); - if !src_dir.exists() { - stats.categories.push(CategoryStats { - name: cat_name.to_string(), - ..Default::default() - }); - continue; + let mut category_stats = if src_dir.exists() { + stat_dir(&src_dir) + } else { + StatAccumulator::default() + }; + if *cat_name == "app" { + category_stats.add(root_prompt_stats.clone()); } - let (fc, tc, tl, cm, rc, _tr, em) = stat_dir(&src_dir); + stats.categories.push(CategoryStats { name: cat_name.to_string(), - file_count: fc, - total_chars: tc, - total_lines: tl, - source_mdx_count: cm, - resource_count: rc, - translated_count: 0, + file_count: category_stats.file_count, + total_chars: category_stats.total_chars, + total_lines: category_stats.total_lines, + source_mdx_count: category_stats.source_mdx_count, + resource_count: category_stats.resource_count, + translated_count: category_stats.translated_count, }); - stats.total_files += fc; - stats.total_chars += tc; - stats.total_lines += tl; - stats.total_source_mdx += cm; - stats.total_resources += rc; - for (k, v) in em { - *all_ext.entry(k).or_default() += v; + + if !PROJECT_SERIES_CATEGORIES.contains(cat_name) { + accumulate_overall_stats(&category_stats, &mut stats, &mut all_ext); } } - // Count translated files in dist/ let dist_dir = base.join("dist"); if dist_dir.exists() { - let (fc, _tc, _tl, _cm, _rc, _tr, _em) = stat_dir(&dist_dir); - stats.total_translated = fc; + stats.total_translated = stat_dir(&dist_dir).file_count; } - // Build extension distribution let mut ext_vec: Vec<_> = all_ext.into_iter().collect(); ext_vec.sort_by(|a, b| b.1.cmp(&a.1)); stats.extensions = ext_vec @@ -692,10 +822,276 @@ pub fn get_aindex_stats(cwd: String) -> Result { .map(|(ext, count)| ExtensionCount { ext, count }) .collect(); - // Sort projects by file count descending stats .projects .sort_by(|a, b| b.file_count.cmp(&a.file_count)); Ok(stats) } + +/// Gather comprehensive statistics about the aindex project. +#[tauri::command] +pub fn get_aindex_stats(cwd: String) -> Result { + let ResolvedConfig { + aindex_root: base, + config, + } = load_resolved_config(&cwd)?; + build_aindex_stats(&base, &config) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn create_temp_dir(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("{prefix}-{unique}")); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + dir + } + + fn create_test_config() -> tnmsc::core::config::UserConfigFile { + serde_json::from_value(serde_json::json!({ + "aindex": { + "app": {"src": "app", "dist": "dist/app"}, + "ext": {"src": "ext", "dist": "dist/ext"}, + "arch": {"src": "arch", "dist": "dist/arch"}, + "skills": {"src": "skills", "dist": "dist/skills"}, + "commands": {"src": "commands", "dist": "dist/commands"}, + "subAgents": {"src": "subagents", "dist": "dist/subagents"}, + "rules": {"src": "rules", "dist": "dist/rules"} + } + })) + .expect("test config should deserialize") + } + + #[test] + fn resolve_category_paths_supports_project_series() { + let config = create_test_config(); + + let app = resolve_category_paths(&config, "app").expect("app paths should resolve"); + let ext = resolve_category_paths(&config, "ext").expect("ext paths should resolve"); + let arch = resolve_category_paths(&config, "arch").expect("arch paths should resolve"); + + assert_eq!(app.source_rel, "app"); + assert_eq!(app.translated_rel, "dist/app"); + assert_eq!(ext.source_rel, "ext"); + assert_eq!(ext.translated_rel, "dist/ext"); + assert_eq!(arch.source_rel, "arch"); + assert_eq!(arch.translated_rel, "dist/arch"); + } + + #[test] + fn collect_project_series_category_files_scans_app_ext_and_arch() { + let base = create_temp_dir("tnmsc-tauri-series-files"); + + let app_src = base.join("app").join("project-a"); + let ext_src = base.join("ext").join("plugin-a"); + let arch_src = base.join("arch").join("system-a"); + let app_dist = base.join("dist").join("app"); + let ext_dist = base.join("dist").join("ext"); + let arch_dist = base.join("dist").join("arch"); + + std::fs::create_dir_all(&app_src).expect("app dir should be created"); + std::fs::create_dir_all(&ext_src).expect("ext dir should be created"); + std::fs::create_dir_all(&arch_src).expect("arch dir should be created"); + std::fs::create_dir_all(app_dist.join("project-a")) + .expect("app dist dir should be created"); + std::fs::create_dir_all(ext_dist.join("plugin-a")).expect("ext dist dir should be created"); + std::fs::create_dir_all(arch_dist.join("system-a")) + .expect("arch dist dir should be created"); + + std::fs::write(app_src.join("agt.src.mdx"), "App").expect("app src file should exist"); + std::fs::write(ext_src.join("agt.src.mdx"), "Ext").expect("ext src file should exist"); + std::fs::write(arch_src.join("agt.src.mdx"), "Arch").expect("arch src file should exist"); + std::fs::write(app_dist.join("project-a").join("agt.mdx"), "App dist") + .expect("app dist file should exist"); + std::fs::write(ext_dist.join("plugin-a").join("agt.mdx"), "Ext dist") + .expect("ext dist file should exist"); + std::fs::write(arch_dist.join("system-a").join("agt.mdx"), "Arch dist") + .expect("arch dist file should exist"); + + let mut entries = Vec::new(); + collect_project_series_category_files( + &base.join("app"), + &base, + "dist/app", + &app_dist, + &mut entries, + ) + .expect("app series files should collect"); + collect_project_series_category_files( + &base.join("ext"), + &base, + "dist/ext", + &ext_dist, + &mut entries, + ) + .expect("ext series files should collect"); + collect_project_series_category_files( + &base.join("arch"), + &base, + "dist/arch", + &arch_dist, + &mut entries, + ) + .expect("arch series files should collect"); + + let source_paths: Vec<_> = entries + .iter() + .map(|entry| entry.source_path.as_str()) + .collect(); + assert!(source_paths.contains(&"app/project-a/agt.src.mdx")); + assert!(source_paths.contains(&"ext/plugin-a/agt.src.mdx")); + assert!(source_paths.contains(&"arch/system-a/agt.src.mdx")); + assert!(entries.iter().all(|entry| entry.translated_exists)); + + std::fs::remove_dir_all(base).expect("temp dir should be removed"); + } + + #[test] + fn collect_root_memory_prompt_files_includes_root_level_sources() { + let base = create_temp_dir("tnmsc-tauri-root-prompts"); + let config = create_test_config(); + std::fs::create_dir_all(base.join("dist")).expect("dist dir should be created"); + std::fs::write(base.join("global.src.mdx"), "Global") + .expect("global source prompt should be created"); + std::fs::write(base.join("workspace.src.mdx"), "Workspace") + .expect("workspace source prompt should be created"); + std::fs::write(base.join("dist").join("global.mdx"), "Global dist") + .expect("global dist prompt should be created"); + + let mut entries = Vec::new(); + collect_root_memory_prompt_files(&base, &config, &mut entries); + entries.sort_by(|a, b| a.source_path.cmp(&b.source_path)); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].source_path, "global.src.mdx"); + assert_eq!(entries[0].translated_path, "dist/global.mdx"); + assert!(entries[0].translated_exists); + assert_eq!(entries[1].source_path, "workspace.src.mdx"); + assert_eq!(entries[1].translated_path, "dist/workspace.mdx"); + assert!(!entries[1].translated_exists); + + std::fs::remove_dir_all(base).expect("temp dir should be removed"); + } + + #[test] + fn collect_category_file_entries_keeps_root_prompts_without_app_directory() { + let base = create_temp_dir("tnmsc-tauri-root-only-files"); + let config = create_test_config(); + std::fs::create_dir_all(base.join("dist")).expect("dist dir should be created"); + std::fs::write(base.join("global.src.mdx"), "Global") + .expect("global source prompt should be created"); + std::fs::write(base.join("workspace.src.mdx"), "Workspace") + .expect("workspace source prompt should be created"); + + let entries = + collect_category_file_entries(&base, &config, "app").expect("app files should collect"); + let source_paths: Vec<_> = entries + .iter() + .map(|entry| entry.source_path.as_str()) + .collect(); + + assert_eq!(entries.len(), 2); + assert!(source_paths.contains(&"global.src.mdx")); + assert!(source_paths.contains(&"workspace.src.mdx")); + + std::fs::remove_dir_all(base).expect("temp dir should be removed"); + } + + #[test] + fn collect_project_series_stats_includes_ext_and_arch_projects() { + let base = create_temp_dir("tnmsc-tauri-series-stats"); + let config = create_test_config(); + std::fs::create_dir_all(base.join("app").join("project-a")) + .expect("app project dir should be created"); + std::fs::create_dir_all(base.join("ext").join("plugin-a")) + .expect("ext project dir should be created"); + std::fs::create_dir_all(base.join("arch").join("system-a")) + .expect("arch project dir should be created"); + std::fs::write( + base.join("app").join("project-a").join("agt.src.mdx"), + "App", + ) + .expect("app project file should be created"); + std::fs::write(base.join("ext").join("plugin-a").join("agt.src.mdx"), "Ext") + .expect("ext project file should be created"); + std::fs::write( + base.join("arch").join("system-a").join("agt.src.mdx"), + "Arch", + ) + .expect("arch project file should be created"); + + let mut stats = AindexStats::default(); + let mut all_ext = std::collections::HashMap::new(); + collect_project_series_stats(&base, &config, &mut stats, &mut all_ext) + .expect("project stats should collect"); + + let names: Vec<_> = stats + .projects + .iter() + .map(|project| project.name.as_str()) + .collect(); + assert!(names.contains(&"project-a")); + assert!(names.contains(&"ext/plugin-a")); + assert!(names.contains(&"arch/system-a")); + + std::fs::remove_dir_all(base).expect("temp dir should be removed"); + } + + #[test] + fn build_aindex_stats_counts_root_memory_prompts() { + let base = create_temp_dir("tnmsc-tauri-root-stats"); + let config = create_test_config(); + std::fs::create_dir_all(base.join("app").join("project-a")) + .expect("app project dir should be created"); + std::fs::create_dir_all(base.join("dist").join("app").join("project-a")) + .expect("app dist dir should be created"); + std::fs::create_dir_all(base.join("dist")).expect("dist dir should be created"); + std::fs::write(base.join("global.src.mdx"), "Global zh") + .expect("global source prompt should be created"); + std::fs::write(base.join("global.mdx"), "Global en") + .expect("global english source should be created"); + std::fs::write(base.join("workspace.src.mdx"), "Workspace zh") + .expect("workspace source prompt should be created"); + std::fs::write(base.join("workspace.mdx"), "Workspace en") + .expect("workspace english source should be created"); + std::fs::write( + base.join("app").join("project-a").join("agt.src.mdx"), + "App project zh", + ) + .expect("app project source should be created"); + std::fs::write(base.join("dist").join("global.mdx"), "Global dist") + .expect("global dist should be created"); + std::fs::write(base.join("dist").join("workspace.mdx"), "Workspace dist") + .expect("workspace dist should be created"); + std::fs::write( + base.join("dist") + .join("app") + .join("project-a") + .join("agt.mdx"), + "App project dist", + ) + .expect("app project dist should be created"); + + let stats = build_aindex_stats(&base, &config).expect("stats should build"); + let app_category = stats + .categories + .iter() + .find(|category| category.name == "app") + .expect("app category should exist"); + + assert_eq!(stats.total_files, 5); + assert_eq!(stats.total_source_mdx, 3); + assert_eq!(stats.total_translated, 3); + assert_eq!(app_category.file_count, 5); + assert_eq!(app_category.source_mdx_count, 3); + + std::fs::remove_dir_all(base).expect("temp dir should be removed"); + } +} diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index a12bf3e2..95927e3e 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "version": "2026.10327.10010", + "version": "2026.10328.106", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/gui/src/api/bridge.test.ts b/gui/src/api/bridge.test.ts index cd273206..cb13a0b1 100644 --- a/gui/src/api/bridge.test.ts +++ b/gui/src/api/bridge.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { PipelineResult, PluginExecutionResult } from '@/api/bridge' +import type { AindexStats, PipelineResult, PluginExecutionResult } from '@/api/bridge' vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), @@ -8,7 +8,15 @@ vi.mock('@tauri-apps/api/core', () => ({ import { invoke } from '@tauri-apps/api/core' -import { cleanOutputs, executePipeline, listPlugins, loadConfig } from '@/api/bridge' +import { + cleanOutputs, + executePipeline, + getAindexStats, + listAindexFiles, + listCategoryFiles, + listPlugins, + loadConfig, +} from '@/api/bridge' const mockedInvoke = vi.mocked(invoke) @@ -187,3 +195,48 @@ describe('listPlugins', () => { await expect(listPlugins('/slow')).rejects.toThrow('timeout') }) }) + +describe('aindex file bridge commands', () => { + it('invokes list_aindex_files with cwd only', async () => { + mockedInvoke.mockResolvedValue([]) + + await listAindexFiles('/workspace') + + expect(mockedInvoke).toHaveBeenCalledWith('list_aindex_files', { + cwd: '/workspace', + }) + }) + + it('invokes list_category_files with the selected category', async () => { + mockedInvoke.mockResolvedValue([]) + + await listCategoryFiles('/workspace', 'ext') + + expect(mockedInvoke).toHaveBeenCalledWith('list_category_files', { + cwd: '/workspace', + category: 'ext', + }) + }) + + it('invokes get_aindex_stats with cwd only', async () => { + const stats: AindexStats = { + totalFiles: 1, + totalChars: 2, + totalLines: 3, + totalSourceMdx: 1, + totalResources: 0, + totalTranslated: 1, + categories: [], + projects: [], + extensions: [], + } + mockedInvoke.mockResolvedValue(stats) + + const result = await getAindexStats('/workspace') + + expect(mockedInvoke).toHaveBeenCalledWith('get_aindex_stats', { + cwd: '/workspace', + }) + expect(result).toEqual(stats) + }) +}) diff --git a/gui/src/i18n/en-US.json b/gui/src/i18n/en-US.json index 89147bcb..0678e6e9 100644 --- a/gui/src/i18n/en-US.json +++ b/gui/src/i18n/en-US.json @@ -46,7 +46,9 @@ "logs.filter.info": "Info", "logs.filter.debug": "Debug", "files.title": "Files", - "files.tab.projects": "Projects", + "files.tab.app": "App", + "files.tab.ext": "Ext", + "files.tab.arch": "Arch", "files.tab.skills": "Skills", "files.tab.commands": "Commands", "files.tab.agents": "Agents", @@ -95,6 +97,9 @@ "dashboard.stats.files": "files", "dashboard.stats.chars": "chars", "dashboard.stats.lines": "lines", + "dashboard.stats.app": "App", + "dashboard.stats.ext": "Ext", + "dashboard.stats.arch": "Arch", "dashboard.stats.skills": "Skills", "dashboard.stats.commands": "Commands", "dashboard.stats.agents": "Agents", diff --git a/gui/src/i18n/zh-CN.json b/gui/src/i18n/zh-CN.json index 3469090a..c72963a5 100644 --- a/gui/src/i18n/zh-CN.json +++ b/gui/src/i18n/zh-CN.json @@ -46,7 +46,9 @@ "logs.filter.info": "信息", "logs.filter.debug": "调试", "files.title": "文件查看", - "files.tab.projects": "项目", + "files.tab.app": "应用", + "files.tab.ext": "扩展", + "files.tab.arch": "架构", "files.tab.skills": "技能", "files.tab.commands": "命令", "files.tab.agents": "代理", @@ -95,6 +97,9 @@ "dashboard.stats.files": "文件", "dashboard.stats.chars": "字符", "dashboard.stats.lines": "行", + "dashboard.stats.app": "应用", + "dashboard.stats.ext": "扩展", + "dashboard.stats.arch": "架构", "dashboard.stats.skills": "技能", "dashboard.stats.commands": "命令", "dashboard.stats.agents": "代理", diff --git a/gui/src/pages/FilesPage.tsx b/gui/src/pages/FilesPage.tsx index 7c5967de..63a56c94 100644 --- a/gui/src/pages/FilesPage.tsx +++ b/gui/src/pages/FilesPage.tsx @@ -9,12 +9,13 @@ import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import { ChevronDown, ChevronRight, RefreshCw, Save } from 'lucide-react' import type { AindexFileEntry } from '@/api/bridge' -import { listAindexFiles, listCategoryFiles, readAindexFile, writeAindexFile } from '@/api/bridge' +import { listCategoryFiles, readAindexFile, writeAindexFile } from '@/api/bridge' import { useFont } from '@/hooks/useFont' import { useTheme } from '@/hooks/useTheme' import { useI18n } from '@/i18n' import { getFileIconUrl, getFolderIconUrl } from '@/lib/file-icons' import { cn } from '@/lib/utils' +import { FILE_CATEGORY_TABS, fileCategoryRootPrefix, type FileCategory } from '@/pages/files-page-categories' import { registerVitesseThemes, vitesseTheme } from '@/themes' // Monaco setup — reuse worker config, register MDX as markdown variant @@ -61,12 +62,17 @@ interface TreeNode { function buildTree(entries: readonly AindexFileEntry[], rootPrefix: string): TreeNode { const root: TreeNode = { name: rootPrefix, path: rootPrefix, isDir: true, children: [] } const dirs = new Map() - dirs.set(rootPrefix, root) + dirs.set('', root) for (const entry of entries) { - const parts = entry.sourcePath.split('/') + const normalizedSourcePath = entry.sourcePath.startsWith(`${rootPrefix}/`) + ? entry.sourcePath.slice(rootPrefix.length + 1) + : entry.sourcePath + const parts = normalizedSourcePath.split('/').filter(part => part.length > 0) + if (parts.length === 0) continue + // Ensure all parent dirs exist - for (let i = 1; i < parts.length - 1; i++) { + for (let i = 0; i < parts.length - 1; i++) { const dirPath = parts.slice(0, i + 1).join('/') if (!dirs.has(dirPath)) { const node: TreeNode = { name: parts[i]!, path: dirPath, isDir: true, children: [] } @@ -264,32 +270,6 @@ const EditorPane: FC = ({ label, fileName, value, original, onC ) } -// --------------------------------------------------------------------------- -// Category types -// --------------------------------------------------------------------------- - -type FileCategory = 'projects' | 'skills' | 'commands' | 'agents' - -const CATEGORY_TABS: readonly { readonly value: FileCategory; readonly labelKey: string }[] = [ - { value: 'projects', labelKey: 'files.tab.projects' }, - { value: 'skills', labelKey: 'files.tab.skills' }, - { value: 'commands', labelKey: 'files.tab.commands' }, - { value: 'agents', labelKey: 'files.tab.agents' }, -] - -/** Root prefix for tree building per category */ -function categoryRootPrefix(cat: FileCategory): string { - if (cat === 'projects') { - return 'app' - } - - if (cat === 'agents') { - return 'subagents' - } - - return cat -} - // --------------------------------------------------------------------------- // Main page // --------------------------------------------------------------------------- @@ -299,7 +279,7 @@ const FilesPage: FC = () => { const { resolved } = useTheme() const { fontCss } = useFont() - const [category, setCategory] = useState('projects') + const [category, setCategory] = useState('app') const [files, setFiles] = useState([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState(null) @@ -346,9 +326,7 @@ const FilesPage: FC = () => { const fetchFiles = useCallback(async () => { setLoading(true) try { - const result = category === 'projects' - ? await listAindexFiles(cwd) - : await listCategoryFiles(cwd, category) + const result = await listCategoryFiles(cwd, category) setFiles(result) } catch (e) { console.error(`[FilesPage] fetchFiles(${category}) failed:`, e) @@ -359,10 +337,7 @@ const FilesPage: FC = () => { useEffect(() => { fetchFiles() }, [fetchFiles]) - const treeRootPrefix = useMemo( - () => files[0]?.sourcePath.split('/')[0] ?? categoryRootPrefix(category), - [files, category] - ) + const treeRootPrefix = useMemo(() => fileCategoryRootPrefix(category), [category]) const tree = useMemo(() => buildTree(files, treeRootPrefix), [files, treeRootPrefix]) const handleSelect = useCallback(async (entry: AindexFileEntry) => { @@ -445,7 +420,7 @@ const FilesPage: FC = () => { {/* Category tabs */}
- {CATEGORY_TABS.map((tab) => ( + {FILE_CATEGORY_TABS.map((tab) => (