diff --git a/.changeset/twelve-pears-vanish.md b/.changeset/twelve-pears-vanish.md new file mode 100644 index 00000000..2e43a68b --- /dev/null +++ b/.changeset/twelve-pears-vanish.md @@ -0,0 +1,5 @@ +--- +'@tanstack/cta-engine': minor +--- + +Added categories, colors, exclusive tagging, addon file attribution utils, and railway addon diff --git a/packages/cta-engine/src/attribution.ts b/packages/cta-engine/src/attribution.ts new file mode 100644 index 00000000..efe4c8ff --- /dev/null +++ b/packages/cta-engine/src/attribution.ts @@ -0,0 +1,245 @@ +import type { + AddOn, + Framework, + Starter, + AttributedFile, + DependencyAttribution, + FileProvenance, + LineAttribution, + Integration, + IntegrationWithSource, +} from './types.js' + +export interface AttributionInput { + framework: Framework + chosenAddOns: Array + starter?: Starter + files: Record +} + +export interface AttributionOutput { + attributedFiles: Record + dependencies: Array +} + +type Source = { sourceId: string; sourceName: string } + +// A pattern to search for in file content, with its source add-on +interface Injection { + matches: (line: string) => boolean + appliesTo: (filePath: string) => boolean + source: Source +} + +function normalizePath(path: string): string { + let p = path.startsWith('./') ? path.slice(2) : path + p = p.replace(/\.ejs$/, '').replace(/_dot_/g, '.') + const match = p.match(/^(.+\/)?__([^_]+)__(.+)$/) + return match ? (match[1] || '') + match[3] : p +} + +async function getFileProvenance( + filePath: string, + framework: Framework, + addOns: Array, + starter?: Starter, +): Promise { + const target = filePath.startsWith('./') ? filePath.slice(2) : filePath + + if (starter) { + const files = await starter.getFiles() + if (files.some((f: string) => normalizePath(f) === target)) { + return { + source: 'starter', + sourceId: starter.id, + sourceName: starter.name, + } + } + } + + // Order add-ons by type then phase (matches writeFiles order), check in reverse + const typeOrder = ['add-on', 'example', 'toolchain', 'deployment'] + const phaseOrder = ['setup', 'add-on', 'example'] + const ordered = typeOrder.flatMap((type) => + phaseOrder.flatMap((phase) => + addOns.filter((a) => a.phase === phase && a.type === type), + ), + ) + + for (let i = ordered.length - 1; i >= 0; i--) { + const files = await ordered[i].getFiles() + if (files.some((f: string) => normalizePath(f) === target)) { + return { + source: 'add-on', + sourceId: ordered[i].id, + sourceName: ordered[i].name, + } + } + } + + const frameworkFiles = await framework.getFiles() + if (frameworkFiles.some((f: string) => normalizePath(f) === target)) { + return { + source: 'framework', + sourceId: framework.id, + sourceName: framework.name, + } + } + + return null +} + +// Build injection patterns from integrations (for source files) +function integrationInjections(int: IntegrationWithSource): Array { + const source = { sourceId: int._sourceId, sourceName: int._sourceName } + const injections: Array = [] + + const appliesTo = (path: string) => { + if (int.type === 'vite-plugin') return path.includes('vite.config') + if ( + int.type === 'provider' || + int.type === 'root-provider' || + int.type === 'devtools' + ) { + return path.includes('__root') || path.includes('root.tsx') + } + return false + } + + if (int.import) { + const prefix = int.import.split(' from ')[0] + injections.push({ + matches: (line) => line.includes(prefix), + appliesTo, + source, + }) + } + + const code = int.code || int.jsName + if (code) { + injections.push({ + matches: (line) => line.includes(code), + appliesTo, + source, + }) + } + + return injections +} + +// Build injection pattern from a dependency (for package.json) +function dependencyInjection(dep: DependencyAttribution): Injection { + return { + matches: (line) => line.includes(`"${dep.name}"`), + appliesTo: (path) => path.endsWith('package.json'), + source: { sourceId: dep.sourceId, sourceName: dep.sourceName }, + } +} + +export async function computeAttribution( + input: AttributionInput, +): Promise { + const { framework, chosenAddOns, starter, files } = input + + // Collect integrations tagged with source + const integrations: Array = chosenAddOns.flatMap( + (addOn) => + (addOn.integrations || []).map((int: Integration) => ({ + ...int, + _sourceId: addOn.id, + _sourceName: addOn.name, + })), + ) + + // Collect dependencies from add-ons (from packageAdditions or packageTemplate) + const dependencies: Array = chosenAddOns.flatMap( + (addOn) => { + const result: Array = [] + const source = { sourceId: addOn.id, sourceName: addOn.name } + + const addDeps = ( + deps: Record | undefined, + type: 'dependency' | 'devDependency', + ) => { + if (!deps) return + for (const [name, version] of Object.entries(deps)) { + if (typeof version === 'string') { + result.push({ name, version, type, ...source }) + } + } + } + + // From static package.json + addDeps(addOn.packageAdditions?.dependencies, 'dependency') + addDeps(addOn.packageAdditions?.devDependencies, 'devDependency') + + // From package.json.ejs template (strip EJS tags and parse) + if (addOn.packageTemplate) { + try { + const tmpl = JSON.parse( + addOn.packageTemplate.replace(/"[^"]*<%[^%]*%>[^"]*"/g, '""'), + ) + addDeps(tmpl.dependencies, 'dependency') + addDeps(tmpl.devDependencies, 'devDependency') + } catch {} + } + + return result + }, + ) + + // Build unified injection patterns from both integrations and dependencies + const injections: Array = [ + ...integrations.flatMap(integrationInjections), + ...dependencies.map(dependencyInjection), + ] + + const attributedFiles: Record = {} + + for (const [filePath, content] of Object.entries(files)) { + const provenance = await getFileProvenance( + filePath, + framework, + chosenAddOns, + starter, + ) + if (!provenance) continue + + const lines = content.split('\n') + const relevant = injections.filter((inj) => inj.appliesTo(filePath)) + + // Find injected lines + const injectedLines = new Map() + for (const inj of relevant) { + lines.forEach((line, i) => { + if (inj.matches(line) && !injectedLines.has(i + 1)) { + injectedLines.set(i + 1, inj.source) + } + }) + } + + attributedFiles[filePath] = { + content, + provenance, + lineAttributions: lines.map((_, i): LineAttribution => { + const lineNum = i + 1 + const inj = injectedLines.get(lineNum) + return inj + ? { + line: lineNum, + sourceId: inj.sourceId, + sourceName: inj.sourceName, + type: 'injected', + } + : { + line: lineNum, + sourceId: provenance.sourceId, + sourceName: provenance.sourceName, + type: 'original', + } + }), + } + } + + return { attributedFiles, dependencies } +} diff --git a/packages/cta-engine/src/environment.ts b/packages/cta-engine/src/environment.ts index 6b7c31d5..2b955a51 100644 --- a/packages/cta-engine/src/environment.ts +++ b/packages/cta-engine/src/environment.ts @@ -21,6 +21,12 @@ import { import type { Environment } from './types.js' +export interface MemoryEnvironmentOutput { + files: Record + deletedFiles: Array + commands: Array<{ command: string; args: Array }> +} + export function createDefaultEnvironment(): Environment { let errors: Array = [] return { @@ -46,7 +52,12 @@ export function createDefaultEnvironment(): Environment { await mkdir(dirname(path), { recursive: true }) return writeFile(path, getBinaryFile(base64Contents) as string) }, - execute: async (command: string, args: Array, cwd: string, options?: { inherit?: boolean }) => { + execute: async ( + command: string, + args: Array, + cwd: string, + options?: { inherit?: boolean }, + ) => { try { if (options?.inherit) { // For commands that should show output directly to the user @@ -106,14 +117,7 @@ export function createDefaultEnvironment(): Environment { export function createMemoryEnvironment(returnPathsRelativeTo: string = '') { const environment = createDefaultEnvironment() - const output: { - files: Record - deletedFiles: Array - commands: Array<{ - command: string - args: Array - }> - } = { + const output: MemoryEnvironmentOutput = { files: {}, commands: [], deletedFiles: [], diff --git a/packages/cta-engine/src/index.ts b/packages/cta-engine/src/index.ts index 395473d6..c9a75b43 100644 --- a/packages/cta-engine/src/index.ts +++ b/packages/cta-engine/src/index.ts @@ -1,7 +1,12 @@ export { createApp } from './create-app.js' +export { computeAttribution } from './attribution.js' export { addToApp } from './add-to-app.js' -export { finalizeAddOns, getAllAddOns, populateAddOnOptionsDefaults } from './add-ons.js' +export { + finalizeAddOns, + getAllAddOns, + populateAddOnOptionsDefaults, +} from './add-ons.js' export { loadRemoteAddOn } from './custom-add-ons/add-on.js' export { loadStarter } from './custom-add-ons/starter.js' @@ -85,6 +90,12 @@ export type { SerializedOptions, Starter, StarterCompiled, + LineAttribution, + FileProvenance, + AttributedFile, + DependencyAttribution, } from './types.js' +export type { AttributionInput, AttributionOutput } from './attribution.js' +export type { MemoryEnvironmentOutput } from './environment.js' export type { PersistedOptions } from './config-file.js' export type { PackageManager } from './package-manager.js' diff --git a/packages/cta-engine/src/template-file.ts b/packages/cta-engine/src/template-file.ts index d2fef9f3..a044dc09 100644 --- a/packages/cta-engine/src/template-file.ts +++ b/packages/cta-engine/src/template-file.ts @@ -9,7 +9,13 @@ import { } from './package-manager.js' import { relativePath } from './file-helpers.js' -import type { AddOn, Environment, Integration, Options } from './types.js' +import type { + AddOn, + Environment, + Integration, + IntegrationWithSource, + Options, +} from './types.js' function convertDotFilesAndPaths(path: string) { return path @@ -50,11 +56,16 @@ export function createTemplateFile(environment: Environment, options: Options) { } } - const integrations: Array['integrations'][number]> = [] + // Collect integrations and tag them with source add-on for attribution + const integrations: Array = [] for (const addOn of options.chosenAddOns) { if (addOn.integrations) { for (const integration of addOn.integrations) { - integrations.push(integration) + integrations.push({ + ...integration, + _sourceId: addOn.id, + _sourceName: addOn.name, + }) } } } diff --git a/packages/cta-engine/src/types.ts b/packages/cta-engine/src/types.ts index 6bdad6d8..56380cb4 100644 --- a/packages/cta-engine/src/types.ts +++ b/packages/cta-engine/src/types.ts @@ -276,3 +276,37 @@ type UIEnvironment = { } export type Environment = ProjectEnvironment & FileEnvironment & UIEnvironment + +// Attribution tracking types for file provenance +export interface LineAttribution { + line: number + sourceId: string + sourceName: string + type: 'original' | 'injected' +} + +export interface FileProvenance { + source: 'framework' | 'add-on' | 'starter' + sourceId: string + sourceName: string +} + +export interface AttributedFile { + content: string + provenance: FileProvenance + lineAttributions: Array +} + +export interface DependencyAttribution { + name: string + version: string + type: 'dependency' | 'devDependency' + sourceId: string + sourceName: string +} + +// Integration with source add-on tracking (used in templates and attribution) +export type IntegrationWithSource = Integration & { + _sourceId: string + _sourceName: string +}