Skip to content

Commit cdb6eef

Browse files
feat: attribution utils (#303)
LGTM!
1 parent 61380b3 commit cdb6eef

File tree

6 files changed

+323
-13
lines changed

6 files changed

+323
-13
lines changed

.changeset/twelve-pears-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/cta-engine': minor
3+
---
4+
5+
Added categories, colors, exclusive tagging, addon file attribution utils, and railway addon
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import type {
2+
AddOn,
3+
Framework,
4+
Starter,
5+
AttributedFile,
6+
DependencyAttribution,
7+
FileProvenance,
8+
LineAttribution,
9+
Integration,
10+
IntegrationWithSource,
11+
} from './types.js'
12+
13+
export interface AttributionInput {
14+
framework: Framework
15+
chosenAddOns: Array<AddOn>
16+
starter?: Starter
17+
files: Record<string, string>
18+
}
19+
20+
export interface AttributionOutput {
21+
attributedFiles: Record<string, AttributedFile>
22+
dependencies: Array<DependencyAttribution>
23+
}
24+
25+
type Source = { sourceId: string; sourceName: string }
26+
27+
// A pattern to search for in file content, with its source add-on
28+
interface Injection {
29+
matches: (line: string) => boolean
30+
appliesTo: (filePath: string) => boolean
31+
source: Source
32+
}
33+
34+
function normalizePath(path: string): string {
35+
let p = path.startsWith('./') ? path.slice(2) : path
36+
p = p.replace(/\.ejs$/, '').replace(/_dot_/g, '.')
37+
const match = p.match(/^(.+\/)?__([^_]+)__(.+)$/)
38+
return match ? (match[1] || '') + match[3] : p
39+
}
40+
41+
async function getFileProvenance(
42+
filePath: string,
43+
framework: Framework,
44+
addOns: Array<AddOn>,
45+
starter?: Starter,
46+
): Promise<FileProvenance | null> {
47+
const target = filePath.startsWith('./') ? filePath.slice(2) : filePath
48+
49+
if (starter) {
50+
const files = await starter.getFiles()
51+
if (files.some((f: string) => normalizePath(f) === target)) {
52+
return {
53+
source: 'starter',
54+
sourceId: starter.id,
55+
sourceName: starter.name,
56+
}
57+
}
58+
}
59+
60+
// Order add-ons by type then phase (matches writeFiles order), check in reverse
61+
const typeOrder = ['add-on', 'example', 'toolchain', 'deployment']
62+
const phaseOrder = ['setup', 'add-on', 'example']
63+
const ordered = typeOrder.flatMap((type) =>
64+
phaseOrder.flatMap((phase) =>
65+
addOns.filter((a) => a.phase === phase && a.type === type),
66+
),
67+
)
68+
69+
for (let i = ordered.length - 1; i >= 0; i--) {
70+
const files = await ordered[i].getFiles()
71+
if (files.some((f: string) => normalizePath(f) === target)) {
72+
return {
73+
source: 'add-on',
74+
sourceId: ordered[i].id,
75+
sourceName: ordered[i].name,
76+
}
77+
}
78+
}
79+
80+
const frameworkFiles = await framework.getFiles()
81+
if (frameworkFiles.some((f: string) => normalizePath(f) === target)) {
82+
return {
83+
source: 'framework',
84+
sourceId: framework.id,
85+
sourceName: framework.name,
86+
}
87+
}
88+
89+
return null
90+
}
91+
92+
// Build injection patterns from integrations (for source files)
93+
function integrationInjections(int: IntegrationWithSource): Array<Injection> {
94+
const source = { sourceId: int._sourceId, sourceName: int._sourceName }
95+
const injections: Array<Injection> = []
96+
97+
const appliesTo = (path: string) => {
98+
if (int.type === 'vite-plugin') return path.includes('vite.config')
99+
if (
100+
int.type === 'provider' ||
101+
int.type === 'root-provider' ||
102+
int.type === 'devtools'
103+
) {
104+
return path.includes('__root') || path.includes('root.tsx')
105+
}
106+
return false
107+
}
108+
109+
if (int.import) {
110+
const prefix = int.import.split(' from ')[0]
111+
injections.push({
112+
matches: (line) => line.includes(prefix),
113+
appliesTo,
114+
source,
115+
})
116+
}
117+
118+
const code = int.code || int.jsName
119+
if (code) {
120+
injections.push({
121+
matches: (line) => line.includes(code),
122+
appliesTo,
123+
source,
124+
})
125+
}
126+
127+
return injections
128+
}
129+
130+
// Build injection pattern from a dependency (for package.json)
131+
function dependencyInjection(dep: DependencyAttribution): Injection {
132+
return {
133+
matches: (line) => line.includes(`"${dep.name}"`),
134+
appliesTo: (path) => path.endsWith('package.json'),
135+
source: { sourceId: dep.sourceId, sourceName: dep.sourceName },
136+
}
137+
}
138+
139+
export async function computeAttribution(
140+
input: AttributionInput,
141+
): Promise<AttributionOutput> {
142+
const { framework, chosenAddOns, starter, files } = input
143+
144+
// Collect integrations tagged with source
145+
const integrations: Array<IntegrationWithSource> = chosenAddOns.flatMap(
146+
(addOn) =>
147+
(addOn.integrations || []).map((int: Integration) => ({
148+
...int,
149+
_sourceId: addOn.id,
150+
_sourceName: addOn.name,
151+
})),
152+
)
153+
154+
// Collect dependencies from add-ons (from packageAdditions or packageTemplate)
155+
const dependencies: Array<DependencyAttribution> = chosenAddOns.flatMap(
156+
(addOn) => {
157+
const result: Array<DependencyAttribution> = []
158+
const source = { sourceId: addOn.id, sourceName: addOn.name }
159+
160+
const addDeps = (
161+
deps: Record<string, unknown> | undefined,
162+
type: 'dependency' | 'devDependency',
163+
) => {
164+
if (!deps) return
165+
for (const [name, version] of Object.entries(deps)) {
166+
if (typeof version === 'string') {
167+
result.push({ name, version, type, ...source })
168+
}
169+
}
170+
}
171+
172+
// From static package.json
173+
addDeps(addOn.packageAdditions?.dependencies, 'dependency')
174+
addDeps(addOn.packageAdditions?.devDependencies, 'devDependency')
175+
176+
// From package.json.ejs template (strip EJS tags and parse)
177+
if (addOn.packageTemplate) {
178+
try {
179+
const tmpl = JSON.parse(
180+
addOn.packageTemplate.replace(/"[^"]*<%[^%]*%>[^"]*"/g, '""'),
181+
)
182+
addDeps(tmpl.dependencies, 'dependency')
183+
addDeps(tmpl.devDependencies, 'devDependency')
184+
} catch {}
185+
}
186+
187+
return result
188+
},
189+
)
190+
191+
// Build unified injection patterns from both integrations and dependencies
192+
const injections: Array<Injection> = [
193+
...integrations.flatMap(integrationInjections),
194+
...dependencies.map(dependencyInjection),
195+
]
196+
197+
const attributedFiles: Record<string, AttributedFile> = {}
198+
199+
for (const [filePath, content] of Object.entries(files)) {
200+
const provenance = await getFileProvenance(
201+
filePath,
202+
framework,
203+
chosenAddOns,
204+
starter,
205+
)
206+
if (!provenance) continue
207+
208+
const lines = content.split('\n')
209+
const relevant = injections.filter((inj) => inj.appliesTo(filePath))
210+
211+
// Find injected lines
212+
const injectedLines = new Map<number, Source>()
213+
for (const inj of relevant) {
214+
lines.forEach((line, i) => {
215+
if (inj.matches(line) && !injectedLines.has(i + 1)) {
216+
injectedLines.set(i + 1, inj.source)
217+
}
218+
})
219+
}
220+
221+
attributedFiles[filePath] = {
222+
content,
223+
provenance,
224+
lineAttributions: lines.map((_, i): LineAttribution => {
225+
const lineNum = i + 1
226+
const inj = injectedLines.get(lineNum)
227+
return inj
228+
? {
229+
line: lineNum,
230+
sourceId: inj.sourceId,
231+
sourceName: inj.sourceName,
232+
type: 'injected',
233+
}
234+
: {
235+
line: lineNum,
236+
sourceId: provenance.sourceId,
237+
sourceName: provenance.sourceName,
238+
type: 'original',
239+
}
240+
}),
241+
}
242+
}
243+
244+
return { attributedFiles, dependencies }
245+
}

packages/cta-engine/src/environment.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import {
2121

2222
import type { Environment } from './types.js'
2323

24+
export interface MemoryEnvironmentOutput {
25+
files: Record<string, string>
26+
deletedFiles: Array<string>
27+
commands: Array<{ command: string; args: Array<string> }>
28+
}
29+
2430
export function createDefaultEnvironment(): Environment {
2531
let errors: Array<string> = []
2632
return {
@@ -46,7 +52,12 @@ export function createDefaultEnvironment(): Environment {
4652
await mkdir(dirname(path), { recursive: true })
4753
return writeFile(path, getBinaryFile(base64Contents) as string)
4854
},
49-
execute: async (command: string, args: Array<string>, cwd: string, options?: { inherit?: boolean }) => {
55+
execute: async (
56+
command: string,
57+
args: Array<string>,
58+
cwd: string,
59+
options?: { inherit?: boolean },
60+
) => {
5061
try {
5162
if (options?.inherit) {
5263
// For commands that should show output directly to the user
@@ -106,14 +117,7 @@ export function createDefaultEnvironment(): Environment {
106117
export function createMemoryEnvironment(returnPathsRelativeTo: string = '') {
107118
const environment = createDefaultEnvironment()
108119

109-
const output: {
110-
files: Record<string, string>
111-
deletedFiles: Array<string>
112-
commands: Array<{
113-
command: string
114-
args: Array<string>
115-
}>
116-
} = {
120+
const output: MemoryEnvironmentOutput = {
117121
files: {},
118122
commands: [],
119123
deletedFiles: [],

packages/cta-engine/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
export { createApp } from './create-app.js'
2+
export { computeAttribution } from './attribution.js'
23
export { addToApp } from './add-to-app.js'
34

4-
export { finalizeAddOns, getAllAddOns, populateAddOnOptionsDefaults } from './add-ons.js'
5+
export {
6+
finalizeAddOns,
7+
getAllAddOns,
8+
populateAddOnOptionsDefaults,
9+
} from './add-ons.js'
510

611
export { loadRemoteAddOn } from './custom-add-ons/add-on.js'
712
export { loadStarter } from './custom-add-ons/starter.js'
@@ -85,6 +90,12 @@ export type {
8590
SerializedOptions,
8691
Starter,
8792
StarterCompiled,
93+
LineAttribution,
94+
FileProvenance,
95+
AttributedFile,
96+
DependencyAttribution,
8897
} from './types.js'
98+
export type { AttributionInput, AttributionOutput } from './attribution.js'
99+
export type { MemoryEnvironmentOutput } from './environment.js'
89100
export type { PersistedOptions } from './config-file.js'
90101
export type { PackageManager } from './package-manager.js'

packages/cta-engine/src/template-file.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import {
99
} from './package-manager.js'
1010
import { relativePath } from './file-helpers.js'
1111

12-
import type { AddOn, Environment, Integration, Options } from './types.js'
12+
import type {
13+
AddOn,
14+
Environment,
15+
Integration,
16+
IntegrationWithSource,
17+
Options,
18+
} from './types.js'
1319

1420
function convertDotFilesAndPaths(path: string) {
1521
return path
@@ -50,11 +56,16 @@ export function createTemplateFile(environment: Environment, options: Options) {
5056
}
5157
}
5258

53-
const integrations: Array<Required<AddOn>['integrations'][number]> = []
59+
// Collect integrations and tag them with source add-on for attribution
60+
const integrations: Array<IntegrationWithSource> = []
5461
for (const addOn of options.chosenAddOns) {
5562
if (addOn.integrations) {
5663
for (const integration of addOn.integrations) {
57-
integrations.push(integration)
64+
integrations.push({
65+
...integration,
66+
_sourceId: addOn.id,
67+
_sourceName: addOn.name,
68+
})
5869
}
5970
}
6071
}

packages/cta-engine/src/types.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,37 @@ type UIEnvironment = {
276276
}
277277

278278
export type Environment = ProjectEnvironment & FileEnvironment & UIEnvironment
279+
280+
// Attribution tracking types for file provenance
281+
export interface LineAttribution {
282+
line: number
283+
sourceId: string
284+
sourceName: string
285+
type: 'original' | 'injected'
286+
}
287+
288+
export interface FileProvenance {
289+
source: 'framework' | 'add-on' | 'starter'
290+
sourceId: string
291+
sourceName: string
292+
}
293+
294+
export interface AttributedFile {
295+
content: string
296+
provenance: FileProvenance
297+
lineAttributions: Array<LineAttribution>
298+
}
299+
300+
export interface DependencyAttribution {
301+
name: string
302+
version: string
303+
type: 'dependency' | 'devDependency'
304+
sourceId: string
305+
sourceName: string
306+
}
307+
308+
// Integration with source add-on tracking (used in templates and attribution)
309+
export type IntegrationWithSource = Integration & {
310+
_sourceId: string
311+
_sourceName: string
312+
}

0 commit comments

Comments
 (0)