- Start Here: A Build Story
- What code-injector is
- The three pieces
- 30-second example
- Concepts: AOP Applied to Source Files
- Engine Architecture
- Aspect Dictionary Reference
- Building a Definition Project
- Wiring an App / Driver Layer
- Running the Injector
- End-to-End Walkthrough
- Tiny Boot Lab
You have a TV app, console access is painful, and the bug only happens on the device. You want one extra line in the app:
console.log('[device] boot reached')But you do not want that line committed to the product repo. You want it only in the temporary checkout that the build system is about to package.
That is the feeling this project is built around.
code-injector lets a separate project say: open this file, find this small piece of
text, and put this other piece of text there. It does that before the normal app build.
The app source stays clean. The throwaway build copy gets the debug behavior.
The rest of this tutorial keeps that one idea alive and slowly makes it more useful.
Start with a tiny app file:
function boot() {
startApp()
}
function startApp() {
renderHome()
}
function renderHome() {
console.log('home rendered')
}
boot()The need is small and familiar:
When this build runs on a device, I want to know whether
boot()was reached.
The definition is also small:
aspects = [
{
"file": "src/app.js",
"aspects": [
{
"pointcut": "function boot() {",
"advice": " console.log('[device] boot reached');",
"position": "after",
"trim-advice": False
}
]
}
]After the injector runs, the build copy becomes:
function boot() {
console.log('[device] boot reached')
startApp()
}
function startApp() {
renderHome()
}
function renderHome() {
console.log('home rendered')
}
boot()That is the whole project in one breath. A rule lives outside the app. The rule touches the app only when the build asks for it.
You can run this first win:
cd /Users/user/workspace/utils/code-injector/lab
./scripts/reset.sh
./scripts/run.sh
sed -n '1,120p' app/src/app.jsRun ./scripts/run.sh again. The log line will not be duplicated. The injector notices
that the advice is already present and skips it.
On a device, signing in with a remote control is slow. A debug build should fill the email and password for you. The real project already does this in:
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/autosignin.py
The target code has calls like this:
setEmailInput(value)
setPasswordInput(value)The aspect changes the function being called:
aspects=[
{
"file": "src/components/RegAndSignInFlow/RegAndSignInFlow.js",
"env":["dev","perf", "test"],
"aspects":[{
"pointcut":'''
setEmailInput(
''',
"advice":'''
remoteLog.onSetEmailInput(setEmailInput,
''',
"position": "replace"
},{
"pointcut":'''
setPasswordInput(
''',
"advice":'''
remoteLog.onSetPasswordInput(setPasswordInput,
''',
"position": "replace"
}]
}
]Now the call becomes:
remoteLog.onSetEmailInput(setEmailInput, value)
remoteLog.onSetPasswordInput(setPasswordInput, value)The interesting part is not just the replacement. It is the line that says:
"env":["dev","perf", "test"]That is the safety catch. The auto sign-in behavior belongs in useful debug builds, not production builds.
Once you inject more than one line, you need a small library behind those lines. In
this project that library is remote-log. The injected code calls it. The library does
the heavier work.
But a copied debug library needs to know which build it is inside and where to send logs. The real project handles that in:
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/remoteConfig.py
The target file has a placeholder:
const version = '100.100.100'
const host = 'ps5.mycompany.com'
export default {
remoteLogHostURL: `${protocol}://${host}/remote-logs`,
performanceURL: `${protocol}://${host}/performance/action`
}The aspect uses the build context and the selected environment:
import codeInjector.context
context=codeInjector.context.getAppContext('html5')
aspects=[
{
"file": "remote-log/config.js",
"aspects":[{
"pointcut":'''
100.100.100
''',
"advice":'''
{version}
'''.format(version=context.version),
"position": "replace"
},{
"pointcut":'''
remoteLogHostURL:`${protocol}://${host}/remote-logs`,
''',
"advice":'''
remoteLogHostURL:`${protocol}://${host}/debug-local/remote-logs`,
''',
"position": "replace",
"env":['dev','test']
},{
"pointcut":'''
performanceURL:`${protocol}://${host}/performance/action`,
''',
"advice":'''
performanceURL:`${protocol}://${host}/debug-local/performance/action`,
''',
"position": "replace",
"env":['dev','test']
}]
}
]A build launched with version 14.2.0-device-debug leaves the copied library with:
const version = '14.2.0-device-debug'
const host = 'ps5.mycompany.com'
export default {
remoteLogHostURL: `${protocol}://${host}/debug-local/remote-logs`,
performanceURL: `${protocol}://${host}/debug-local/performance/action`
}The build now has an identity. The logs arriving on the server can tell you exactly which package produced them.
The app already logs. The problem is that the logs are trapped on the device. The real
logging.py aspect changes the logging helper instead of chasing every log call.
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/logging.py
The app has a helper that calls:
proxy('player', message)The aspect replaces the beginning of that call:
{
"file": "src/helpers/log.js",
"aspects":[{
"pointcut":'''
proxy(
''',
"advice":'''
remoteLog.onProxyLogs(proxy,
''',
"position": "replace"
}]
}The build copy now calls:
remoteLog.onProxyLogs(proxy, 'player', message)The original logger still exists. The debug library sits in front of it, records what it needs, then lets normal logging continue.
The same aspect file also configures which log families are enabled:
"advice":'''
logs.request = {request};
logs.player = {player};
logs.storage = {storage};
logs.performance = {performance};
logs.observable = {observable};
logs.t4.enabled ={t4_enabled};
'''.format(
request=html5.config.tracingOptions.logging.request.value,
player=html5.config.tracingOptions.logging.player.value,
storage=html5.config.tracingOptions.logging.storage.value,
performance=html5.config.tracingOptions.logging.performance.value,
observable=html5.config.tracingOptions.logging.observable.value,
t4_enabled=html5.config.tracingOptions.logging.t4.value)That is when aspects become more than search-and-replace. They become a way to turn a build profile into code.
When a device says "something failed," the next question is usually: which request? Which URL? Did the response arrive? Did the exception happen before or after the response?
The real project answers that in:
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/httpRequest.py
The target helper contains familiar pieces:
return Promise.race(promiseArray)
.then(async res => {
return res
})
.catch(async err => {
throw err
})
export default makeRequestThe aspect adds a start time before the request race:
{
"pointcut":'''
return Promise.race(promiseArray)
''',
"advice":'''
const requestStartTimeForRemoteLog = Date.now();
''',
"position": "before"
}It records the response:
{
"pointcut":'''
.then(async res => {
''',
"advice":'''
remoteLog.onHttpResponseReceived(url, res, requestStartTimeForRemoteLog);
''',
"position": "after"
}It records the exception:
{
"pointcut":'''
.catch(async err => {
''',
"advice":'''
remoteLog.onErrorException('makeRequest',err,[url]);
remoteLog.remoteLog.error(err+" while making requesting to: "+url);
''',
"position": "after"
}And at the end it wraps the exported function:
{
"pointcut":'''
export default makeRequest;
''',
"advice":'''
const remoteMakeRequest = inputParam => remoteLog.onMakeRequest(makeRequest, inputParam);
export default remoteMakeRequest;
''',
"position": "replace"
}The final build copy has a request story: when it started, what response came back, and what error happened if it failed.
Vendor files often contain a real production endpoint. For device debugging, you may need those calls to go somewhere else.
The Adobe example is exactly that:
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/adobe/changeAdobeTargetHost.py
The target files contain:
const primaryAdobeHost = 'analytics.mycompany.com'
const fallbackAdobeHost = 'analytic2.mycompany.com'The aspect takes the replacement host from the active tracing options:
import html5.config
aspects=[{
"file": "src/services/tracking/adobeAnalytics/adobeAnalyticsHelpers.js",
"aspects":[{
"pointcut":'''
analytics.mycompany.com
''',
"advice":'''
{adobeTargetHost}
'''.format(adobeTargetHost=html5.config.tracingOptions.adobeTargetHost.value),
"position": "replace"
},{
"pointcut":'''
analytic2.mycompany.com
''',
"advice":'''
{adobeTargetHost}
'''.format(adobeTargetHost=html5.config.tracingOptions.adobeTargetHost.value),
"position": "replace"
}]
},{
"file": "src/static/lib/appmeasurement/VisitorAPI.js",
"aspects":[{
"pointcut":'''
analytics.mycompany.com
''',
"advice":'''
{adobeTargetHost}
'''.format(adobeTargetHost=html5.config.tracingOptions.adobeTargetHost.value),
"position": "replace"
},{
"pointcut":'''
analytic2.mycompany.com
''',
"advice":'''
{adobeTargetHost}
'''.format(adobeTargetHost=html5.config.tracingOptions.adobeTargetHost.value),
"position": "replace"
}]
}
]If the option says:
html5.config.tracingOptions.adobeTargetHost.value == "adobe-debug.example.local"the build copy becomes:
const primaryAdobeHost = 'adobe-debug.example.local'
const fallbackAdobeHost = 'adobe-debug.example.local'The Iterative aspect does the same kind of thing for iterative.net:
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/iterative/changeIterativeDomain.py
That is a clean pattern: hard-coded vendor value in source, build-profile value in the definition project, replaced only for the build that asked for it.
Real products have branches where the same intent is written a little differently. The Tizen widget name aspect handles that without duplicating the whole job:
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/changeWgtFileName.py
One branch says:
<name xml:lang="en-gb">Media APP</name>Another says:
<name xml:lang="en-gb">TV Application</name>The aspect accepts either:
"pointcut":['''
<name xml:lang="en-gb">Media APP</name>
''','''
<name xml:lang="en-gb">TV Application</name>
'''],
"advice":'''
<name>{wgtFileName}</name>
<name xml:lang="en-gb">TV Application</name>
'''.format(wgtFileName=html5.config.tracingOptions.tizenWgtFileName.value),
"position": "replace",
"skip-if-found":"<name>{wgtFileName}</name>".format(
wgtFileName=html5.config.tracingOptions.tizenWgtFileName.value)The skip-if-found line matters. If the widget name is already there, the aspect backs
away. That keeps repeated runs calm.
Performance work usually starts with a rough question:
Where did the launch time go?
The real performance.py aspect answers by placing markers at important moments.
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/performance.py
It starts the clock in HTML:
{
"file": "src/static/index.html",
"aspects":[{
"pointcut":'''
<head>
''',
"advice":'''
<script type="text/javascript">window.dilshatStartTime=new Date();</script>
''',
"position": "after"
}]
}It records that the app shell mounted:
{
"file": "src/client/AppShell.js",
"aspects":[{
"pointcut":'''
importAndLoadXdk();
''',
"advice":'''
remoteLog.performance.onAppShellMounted();
'''
}]
}It records app configuration after a tracking launch call, even if the exact arguments change:
{
"pointcut":'''
tracking\.launch\(.*\);
''',
"advice":'''
remoteLog.performance.onAppConfigured();
''',
"match-type":"regex",
"position": "after"
}It handles branch-specific Home page code:
{
"pointcut":'''
useRegisterFocusable(pageId);
''',
"advice":'''
remoteLog.performance.useHomePerformance(undefined,items,homeResponse?.isFetching);
''',
"position": "after",
"branch-to-skip":"performance/JIRA-6143-poc"
},{
"pointcut":'''
useRegisterFocusable(pageId);
''',
"advice":'''
remoteLog.performance.useHomePerformance(undefined,homeData && homeData.slices,(!dataReady && heroReady));
''',
"position": "after",
"branch":"performance/JIRA-6143-poc"
}It also protects optional platform files:
{
"file": "src/components/player/Player/YouviewPlayer.js",
"skip-if-file-not-found":True,
"aspects": [{
"pointcut":'''
environment.addEventListener(environment.MEDIA.TIME_UPDATE, onTimeUpdate);
''',
"advice":'''
environment.addEventListener(environment.MEDIA.TIME_UPDATE, remoteLog.performance.onMediaTimerUpdate);
''',
"position":"before"
}]
}This is where the injector starts to feel like a build instrument panel. One job can touch HTML, React views, player code, branch variants, and optional platform files.
Sometimes the problem is not runtime observability. Sometimes the checkout cannot even build with the current toolchain.
The real build-fix aspect lives here:
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/fixes/fix_build_error.py
It bumps package versions:
{
"file": "package.json",
"aspects":[{
"pointcut":'''
"node-sass": "^4.14.1",
''',
"advice":'''
"node-sass": "^6.0.1",
''',
"position": "replace"
},{
"pointcut":'''
"sass-loader": "^9.0.3",
''',
"advice":'''
"sass-loader": "^10.1.1",
''',
"position": "replace"
}]
}Some build-time additions are too large to write inside a Python string. The automation and memory-report tools solve that by reading HTML, CSS, or JS files from beside the aspect.
From:
/Users/user/workspace/utils/remote-log-adapter/html5App/aspects/tool/my4autoplay.py
script_dir = os.path.dirname(os.path.abspath(__file__))
def get_file_content(directory, filename):
file_path = os.path.join(directory, filename)
with open(file_path, 'r') as file:
return file.read()
def get_script_file(directory, filename):
return get_file_content(os.path.join(script_dir, directory), filename)
html_content = get_script_file( 'html', 'my4autoplay.html')
css_content = get_script_file('css', 'overlay.css')Then the aspect injects those files into index.html:
automationNotification=[{
"file":"src/static/index.html",
"aspects":[{
"pointcut":'''</style>''',
"advice":'''
{cssContent}
'''.format(cssContent=css_content),
"position": "before"
},{
"pointcut":'''</body>''',
"advice":'''
{htmlContent}
'''.format(htmlContent=html_content),
"position": "before"
}]
}]That is a good rule of thumb: keep the aspect as the wiring, and keep large payloads as real files.
The tutorial started with one line after boot(). The real project uses the same move
for bigger needs:
Need: I cannot see what the device is doing.
Move: Insert a tiny hook that calls remote-log.
Need: I cannot type credentials on a TV remote.
Move: Replace input setters with auto sign-in wrappers, but only for dev/perf/test.
Need: The vendor endpoint must change for this build.
Move: Replace literal hosts with values from the selected options profile.
Need: A branch changed the code shape.
Move: Give the aspect more than one acceptable needle, or gate by branch.
Need: The platform file is not present in every checkout.
Move: Skip that file when it is missing.
Need: The build pipeline needs a temporary fix.
Move: Patch the disposable checkout, not the product source.
If you keep that pattern in your head, the aspect files stop looking like a bag of dictionary keys. They become build stories: here is the pain, here is the tiny hook, here is the copied build doing the job.
code-injector is a small Python engine that modifies the source files of another
project at build time using the ideas of Aspect-Oriented Programming (AOP).
Instead of editing the main project to add logging, instrumentation, environment tweaks, or build fixes, you keep those modifications in a separate project. At build time the injector weaves them into a copy of the main project's source, builds it, and leaves the main project's repository untouched. This gives you a clean separation of concerns: the product code stays product code, and the debug/instrumentation/build concerns live on their own.
┌─────────────────┐ reads ┌──────────────────────┐ edits ┌──────────────────┐ build ┌──────────────────┐
│ Definition │───────────────▶│ code-injector │────────────▶ │ Main project │ ──────▶ │ App bundle │
│ project │ aspects + │ (engine) │ in place │ source files │ │ │
│ │ advice code │ │ │ (before build) │ │ │
└─────────────────┘ └──────────────────────┘ └──────────────────┘ └──────────────────┘
▲ ▲
│ import path (PYTHONPATH) │ build/deploy parameters
└──────────────── Driver / app layer ─┘ (--app --job --env --branch --version)
| Piece | Responsibility |
|---|---|
| Engine | Generic. Knows how to find a pointcut in a file and insert/replace/remove advice. Knows nothing about any project. |
| Definition project | Owns what to inject and where: the aspect dictionaries and the code to inject (the "advisors"). |
| Driver / app layer | Owns orchestration: turns build/deploy parameters (env, branch, version, platform) into engine calls. |
They are connected through environment variables like:
export PYTHONPATH=/.../code-injector:/..../inject-definition-projectThe bundled sample injects two console.log calls into sampleCode/sample.js:
cd /.../code-injector
python . --app html5 --job sample_job --branch main --version 1.0.0 --env devTarget before:
let a = 5
let b = 10Target after:
let a = 5
console.log('Assigned a to 5')
console.log('About to assign b to 10')
let b = 10The what (console.log(...)) and where (let a = 5; / let b = 10;) come from
html5/aspects/sampleJob.py. The engine never had to know about this file.
Some concerns cut across a whole codebase: logging, tracing, performance instrumentation, environment-specific tweaks, temporary build fixes. If you edit the main project to add them, you mix product code with throwaway/debug code, and you have to carefully unpick it later. Aspect-Oriented Programming keeps those cross-cutting concerns in their own modules ("aspects") and weaves them into the target only when needed.
This tool applies that idea at the source-text / build-time level. It does not run inside your program; it edits files on disk before your normal build runs. Think of it as a programmable, condition-aware "find and insert/replace" that is organised the way AOP frameworks are organised.
| AOP term | Meaning here | Where it lives |
|---|---|---|
| Join point | A location in a source file where you could weave code (a line of code, a function call). | The target project's files. |
| Pointcut | The text/pattern that selects join points. | The pointcut key of an aspect. |
| Advice | The code to weave in, and where relative to the join point (before / after / replace / remove). | The advice + position keys. |
| Aspect | One pointcut+advice rule (plus conditions) for one file. | A dict inside the aspects list. |
| Weaving | The act of applying advice at the matched join points. | codeInjector.aspectHandlers.handleAspect. |
| Advisor | (Project-specific term in the aspect project) the actual library/snippet code that the advice calls into. | aspect definition project |
Everything centres on one Python data shape. A definition file exposes a list called
aspects:
aspects = [
{
"file": "src/helpers/log.js", # target file (relative to the project folder)
"aspects": [ # one or more rules for THIS file
{
"pointcut": "proxy(", # the join point: text to find
"advice": "remoteLog.onAppProxyLogs(proxy, ", # what to weave
"position": "replace" # how to weave it (before/after/replace/remove)
}
]
},
]Read it as: "In src/helpers/log.js, find proxy( and replace it with
remoteLog.onAppProxyLogs(proxy, ." The engine consumes exactly this shape — see
Aspect Dictionary Reference for every supported key.
Each run carries a small context — env, branch, version — describing the
build. Aspects can be made conditional on it (e.g. only inject auto-sign-in on
dev/perf/test, never on prod). The context is created from CLI arguments and
stored per "app" (see context.py).
- External aspect dictionaries (the normal way) — Python files listing
{file, aspects:[…]}. Covered throughout this guide. - Inline
@aspectannotations — special comments left inside the target source that the engine "activates" by stripping the comment markers. Useful when the code to inject is large and you'd rather keep it next to the code it modifies. Covered in Inline@aspectannotations.
The engine is text-based, not AST-based. A pointcut is matched as literal text
(exact), a regular expression (regex), or a function call whose arguments are
copied (function). This keeps the engine language-agnostic — it has been used on
JavaScript/JSX, but nothing ties it to a particular language.
The engine is the codeInjector/ Python package. It is generic: it contains no
reference to any specific project. Below, each module is described with its public
surface and the behaviour that matters when you write aspects.
codeInjector/
├── context.py # per-app build context (branch / version / env)
├── aspectConditions.py # decides IF an aspect should run
├── aspectHandlers.py # performs the weaving (pointcut → advice)
├── aspectRunners.py # the top-level loop over files and aspects
├── textUtil.py # text search + newline-aware insert/replace/remove
├── fileutil.py # read / write / copy files & folders
└── printers.py # coloured console output
The call flow for one run is:
runAspectIn(aspects, folder, onEachFile, context) # aspectRunners.py
for each aspect-group (one target file):
checkAspectCondition(group, context, file) # aspectConditions.py → skip?
content = readFile(folder/file) # fileutil.py
for each inner aspect:
checkAspectCondition(inner, context, file, content) → skip?
handleAspect(inner, content, context) # aspectHandlers.py → new content
(if pointcut not found and match-type=function: copy params and retry)
onEachFile(inner, file, content) # caller's post-process hook
writeFile(folder/file, content) # fileutil.py
Holds the build context per "app" (an app is just a named target, e.g. html5,
uwp, cast).
ContextInfo— fieldsbranch,version,env.apps— a module-level dict{appName: ContextInfo}.createApp(appName, branch, version, env)— registers/updates an app's context. Called once per run from the CLI argument processor.getAppContext(appName)— fetch (creating an empty one if needed).setVersionSuffix(appName, versionSuffix)— adjust the version string:- if
versionSuffixstarts withREPLACE:→ the version is replaced by the text after the prefix; - otherwise the text is appended to the existing version.
This is how
options/*.jsoncan tag a build (e.g. append-perf) — see options/*.json.
- if
checkAspectCondition(aspect, context, file, content=None) returns True if the
aspect should be applied. It is called twice: once on the aspect group (with no
content, before the file is read) and once on each inner aspect (with content).
Conditions, in evaluation order:
| Key | Effect |
|---|---|
disabled: True |
Always skip. |
env |
Must match context.env. String = equality; list = membership. |
branch |
Must match context.branch (string or list). |
branch-to-skip |
Skip if it matches context.branch. |
skip-if-file-not-found: True |
Skip if the target file does not exist on disk. |
skip-if-not-found |
(needs content) Skip unless the given text(s) are present. String or list — for a list, all must be present. |
skip-if-found |
(needs content) Skip if the given text(s) are present. String or list — any match skips. |
advice + skip-if-advice-found |
If skip-if-found is not set, the engine derives an idempotency check from the advice itself: if the advice text is already in the file, skip. Controlled by skip-if-advice-found (default True) and trim-advice (default True). Works for both string advice and {before, after} advice. |
The advice-derived check is what makes injection idempotent by default: running
twice will not double-inject, because the second run sees its own advice already
present and skips.
contextMatch(valueInAspect, valueInContext, illegalTypeValue) implements the
string-equality / list-membership comparison used for env and branch.
The weaver. handleAspect(aspect, content, context) returns (message, newContent);
an empty message means success, a non-empty one is an error string.
Pointcut handling:
pointcutmissing → error.pointcut == '@aspect'→ delegate tohandleAnnotatedAspect(inline annotation mode, below).- otherwise the pointcut is searched repeatedly through the file (so every occurrence is processed, not just the first).
match-type (default exact):
exact— literal text search.regex—re.searchpattern.function— literal search, but with the special argument-copying retry handled inaspectRunners.py(see below).
position decides what happens at each match:
position |
Behaviour |
|---|---|
before (default) |
Insert advice immediately before the match. |
after |
Insert advice immediately after the match. |
replace |
Replace the matched text with the advice. |
remove |
Delete the matched text (no advice needed). |
advice may be:
- a string — inserted/replacing as above; or
- a dict
{before, after}— wrap the match withbeforetext in front andaftertext behind (either side may be omitted).
trim-pointcut / trim-advice (both default True) strip surrounding whitespace from
the multi-line triple-quoted strings that are convenient to write in Python.
optional: True — if the pointcut is never found, succeed silently instead of
returning a "not found" error.
Activates code that is parked inside the target file as a comment:
/* @aspect'''
remoteLog.trace('here');
''' */The engine finds each comment, looks for the @aspect''' … ''' annotation inside it,
and rewrites the file so the comment markers are removed and the inner text becomes
live code. This lets you keep large advice next to the code it touches.
A project-specific convenience used as an onEachFile post-processor: if an aspect's
advice references remoteLog, it ensures the file has
import * as remoteLog from '~/remote-log'; at the top (inserted after the existing
import block via textUtil.insertImportStatement). It is idempotent.
runAspectIn(aspects, folder, onEachFile, context) is the entry point a driver calls.
- Iterates the list of aspect groups; for each, resolves
folder + '/' + file. - Skips groups whose group-level conditions fail.
- Reads the file once, applies every inner aspect in order, then writes it back.
- Function-call retry: if
handleAspectreports the pointcut "not found" and the aspect usesmatch-type: function, it callsprocessPointFunctionCall, which usestextUtil.copyInputParametersto copy the actual argument list from the target's function call into both the pointcut and the advice, then retries. This lets you rewritefoo(a, b, c)→wrap(foo, a, b, c)without knowing the arguments in advance. onEachFile(aspect, file, content)is the caller's hook, run after a successful weave — used for linting and theremoteLogimport injection in the real driver.- Errors are printed via
printers.printError(the file is still written with whatever succeeded).
The text-manipulation core.
ContentRangewraps the file content and a moving[start, end)cursor. Its insert/replace/remove methods keep the cursor positioned correctly so repeated matches advance through the file. Crucially,getSeparatorsinspects the surrounding characters and adds\nonly where needed, so injected lines land on their own line without creating blank-line noise.findText(contentRange, textToFind, matchType)—exact/functionuse literalfind;regexusesre.search.findComment/findAdviceAnnotation— locate//and/* */comments and the@aspect''' '''annotation for inline-aspect mode.insertImportStatement/findHeadStatementInsertPosition— find the end of the leadingimport …;block (skipping//and/* */comments) and insert a new import there.copyInputParameters/parse_parentheses/replace_parameters— the machinery behindmatch-type: function.
fileutil.readFile/writeFile— plain text IO.fileutil.copyFolderIfEmpty(source, target, minNumberOfFilesInDest)— copy a folder (e.g. the injectedremote-loglibrary) into the target project only if it isn't already there. Used by the driver to seed advisor code before weaving.printers.printError/printInfo— coloured console messages. Note the driver treats any stdout/stderr from the Python process as a failure signal (see Running the Injector), so the engine stays quiet on success.
This is the complete reference for the data shape the engine consumes. An aspect
definition module exposes a Python list named aspects:
aspects = [
{
"file": "<path relative to the target project folder>",
# group-level conditions may also go here (env, branch, ...)
"aspects": [
{ "pointcut": ..., "advice": ..., "position": ..., ... },
...
]
},
...
]There are two condition layers:
- Group level (the outer dict, alongside
file) — checked once, before the file is read. Use it to skip an entire file for an env/branch, or when the file may not exist. - Aspect level (each inner dict) — checked with the file content available, so it
can also test
skip-if-found/skip-if-not-found.
| Key | Type | Default | Meaning |
|---|---|---|---|
file |
string | — (required, group level) | Target file, relative to the project folder configured in config.py. |
pointcut |
string | list | '@aspect' |
— (required, aspect level) | Text/pattern selecting the join point(s). A list is tried in order until one matches. '@aspect' switches to inline-annotation mode. |
match-type |
exact | regex | function |
exact |
How pointcut is matched. regex uses re.search; function enables argument copying. |
trim-pointcut |
bool | True |
Strip surrounding whitespace from pointcut (so you can use indented triple-quoted strings). |
| Key | Type | Default | Meaning |
|---|---|---|---|
advice |
string | {before, after} |
— | Code to weave. A dict wraps the match (before in front, after behind); either side may be omitted. Not required when position: remove. |
position |
before | after | replace | remove |
before |
Where the advice goes relative to the match. |
trim-advice |
bool | True |
Strip surrounding whitespace from advice text. |
| Key | Type | Level | Meaning |
|---|---|---|---|
disabled |
bool | both | True → never run this aspect. |
env |
string | list | both | Run only when context.env matches. |
branch |
string | list | both | Run only when context.branch matches. |
branch-to-skip |
string | list | both | Skip when context.branch matches. |
skip-if-file-not-found |
bool | both | Skip if the target file is missing (instead of erroring). |
skip-if-not-found |
string | list | aspect | Run only if the text(s) are present in the file. List ⇒ all required. |
skip-if-found |
string | list | aspect | Skip if the text(s) are present. List ⇒ any match skips. |
skip-if-advice-found |
bool | aspect | Default True. When skip-if-found is absent, the advice text itself is used as the "already injected?" check. Set False to allow re-injection. |
optional |
bool | aspect | True → if the pointcut is never found, succeed silently (no error). |
Idempotency: because
skip-if-advice-founddefaults toTrue, re-running the injector does not double-apply advice. If you intend to inject the same text in several places, give each its own distinct advice or setskip-if-foundexplicitly.
{
"file": "src/helpers/log.js",
"aspects": [{
"pointcut": "proxy(",
"advice": "remoteLog.onAppProxyLogs(proxy, ",
"position": "replace"
}]
}Turns proxy(...) into remoteLog.onAppProxyLogs(proxy, ...).
{
"file": "remote-log/config.js",
"aspects": [{
"pointcut": "const initConfig = ()=>{",
"advice": '''
logs.request = {request};
logs.player = {player};
'''.format(request=html5.config.tracingOptions.logging.request.value,
player=html5.config.tracingOptions.logging.player.value),
"position": "after"
}]
}The advice is a normal Python string, so values from the loaded options/*.json
(surfaced through tracingOptions) can be substituted with .format(...).
{
"file": "src/components/RegAndSignInFlow/RegAndSignInFlow.js",
"env": ["dev", "perf", "test"], # group-level condition
"aspects": [
{"pointcut": "setEmailInput(",
"advice": "remoteLog.onSetEmailInput(setEmailInput, ",
"position": "replace"},
{"pointcut": "setPasswordInput(",
"advice": "remoteLog.onSetPasswordInput(setPasswordInput, ",
"position": "replace"},
]
}{
"file": "src/components/ErrorBoundary/ErrorBoundary.js",
"aspects": [{
"pointcut": "static getDerivedStateFromError() {",
"advice": "static getDerivedStateFromError(error) {\n log.error('[ErrorBoundary] ' + error);",
"position": "replace",
"optional": True # don't fail the build if the signature changed
}]
}{
"file": "src/player/index.js",
"aspects": [{
"pointcut": "createPlayer(",
"advice": "traceCreatePlayer(",
"match-type": "function",
"position": "replace"
}]
}If the file contains createPlayer(a, b, opts), the engine copies the argument list so
the result is traceCreatePlayer(a, b, opts) — without you naming the arguments.
Sometimes the advice is large and lives best beside the code it modifies. Park it inside the target file as a commented annotation:
function start() {
/* @aspect'''
remoteLog.trace('start() entered');
remoteLog.attachTimings(performanceMonitor);
''' */
}Then a single aspect activates all such annotations in the file:
{
"file": "src/app/start.js",
"aspects": [{ "pointcut": "@aspect" }]
}The engine strips the comment markers and the @aspect'''…''' wrapper, leaving the
inner lines as live code. (See handleAnnotatedAspect in
handleAnnotatedAspect.)
A definition module can build its aspects list from several blocks — handy for
grouping related rules:
aspects = [ ... core rules ... ]
errorBoundary = [ { "file": ..., "aspects": [ ... ] } ]
aspects.extend(errorBoundary)A definition project is a normal Python package that owns what to inject and where. It contains two kinds of artefact:
- Aspect dictionaries — Python modules exposing
aspectslists (the rules). - Advisors / payload code — the actual code that gets injected or copied.
The engine never imports your definition project directly; the driver does, after
you put the project on PYTHONPATH. So a definition project's only hard requirement is:
be importable, and expose aspects lists in modules the driver names.
aspect-project/
├── html5App/
│ └── aspects/
│ ├── logging.py # aspect dicts: where logging is woven in
│ ├── autosignin.py # aspect dicts: auto sign-in (dev/perf/test only)
│ ├── performance.py # aspect dicts: performance instrumentation
│ ├── httpRequest.py
│ ├── setversions.py
│ ├── advisors/ # the JS "advisors" that the advice calls into
│ │ └── remote-log/ # a library copied into the target project
│ ├── fixes/ # build-fix aspect groups
│ ├── build/ # prebuild preparation aspects
│ └── … # adobe/, observable/, iterative/, video/, youview/, …
├── lib/
│ └── remoteLogs.js # static lib copied into the target as src/static/remoteLogs.js
└── patches/ # raw .patch files for changes too large for aspects
Key points:
- The driver imports these as
html5App.aspects.<module>(e.g.import html5App.aspects.logging). That works because the aspect project is onPYTHONPATH, makinghtml5Appa top-level package. The folder namehtml5Apptherefore matters — it is the import root the driver expects. - Subfolders (
fixes/,build/,adobe/, …) just group related aspect modules; the driver imports whichever ones a given job needs.
A module is just Python that ends with a list called aspects (the name the driver
passes to runAspectIn). Minimal:
# html5App/aspects/mytrace.py
aspects = [
{
"file": "src/app/boot.js",
"aspects": [{
"pointcut": "function boot() {",
"advice": "remoteLog.trace('boot');",
"position": "after"
}]
}
]Because it is real Python you can:
- import config to parameterise advice (
import html5.configthenhtml5.config.tracingOptions…); .format(...)values into advice strings;- build the list conditionally,
extend()it from several blocks, generate it in a loop, etc.
See Aspect Dictionary Reference for every key you can use.
"Advice" in the aspect dict is usually a small hook that calls into a larger body of code you ship with the definition project. Two common patterns :
- Copy a folder once, then weave imports/calls. The driver calls
fileutil.copyFolderIfEmpty(source, dest, minFiles)to drop the advisor library (e.g.advisors/remote-log→<target>/remote-log) into the target, then runs the aspects that wire it up. (copyRemoteLogFolderin the driver.) - Copy a single static file, then weave.
lib/remoteLogs.jsis copied to<target>/src/static/remoteLogs.js, then aspects reference it. (copyStaticRemoteLogFilein the driver.)
This keeps the injected lines tiny (just a call or import) while the substance lives as ordinary, testable source in the definition project.
For changes too large or too structural for text pointcuts (e.g. multi-file vendor
edits), .../patches/<branch>/<ticket>/… stores raw .patch files applied by
the driver/build outside the injector. Treat patches as the escape hatch; prefer
aspects for anything you want to keep readable and condition-aware.
- Keep advice minimal and idempotent. Inject a call, not a body. The default
skip-if-advice-foundthen protects you from double injection. - Gate by
env/branchso debug-only concerns never reach production builds. - Mark fragile pointcuts
optionalso a refactor in the main project degrades gracefully instead of failing the build — but only where a silent skip is acceptable. - Prefer
match-type: functionwhen wrapping calls whose arguments you don't want to hard-code.
The driver/app layer turns build/deploy parameters into engine calls. It answers:
for this --app and --job, which aspect modules do I import, and how do I run them?
There are two concrete examples in the codebase:
- The bundled minimal demo —
code-injector/html5/+code-injector/__main__.py. - The real deployment driver —
aspects/pythons/(index.py,html5/{config,actions,trackingOption}.py,options/*.json) under.../aspects/.
Both follow the same three-part shape: an entry point that parses args, a config that locates the target and definition projects, and an actions class that maps jobs to aspect runs.
Parses CLI options, builds the context, dispatches to the app action.
Demo (__main__.py) accepts: --app --branch --version --env --job.
Real driver (index.py) adds: --options and --extra.
# simplified from the real index.py
configArguments = ['branch=', 'version=', 'env=', 'job=', 'app=', 'options=', 'extra=']
class ArgumentProcessor:
def processArguments(self, argv):
opts, _ = getopt.getopt(argv, "", configArguments)
for opt, arg in opts:
... # store branch/version/env/job/app/options/extra
codeInjector.context.createApp(self.app, self.branch, self.version, self.env)
def execute(self):
appAction = self.createAppAction(self.app) # html5 / uwp / cast / poc
self.loadJsonOption(appAction, self.options) # read options/*.json, set tracingOptions + versionSuffix
appAction.execute(self.extra)Two responsibilities worth noting:
createApp(...)seeds the build context (env/branch/version) the engine will use for condition checks.loadJsonOption(...)readsoptions/<name>.json, feeds it toappAction.setTracingOptions(...), and — if the JSON has acontext.versionSuffix— callscontext.setVersionSuffix(...)(append, or replace via theREPLACE:prefix).
Locates the target project and points at the definition project.
Demo:
name = 'html5'
folder = "./sampleCode" # the target project to editReal:
name = 'html5'
folder = os.environ.get('react_html5_projectfolder') # target taken from the build env
aspects = "/…/html5App/aspects" # definition project paths
advistors = "/…/html5App/aspects/advisors"
# + helpers describing files to copy in (remote-log folder, remoteLogs.js),
# user accounts, secrets, and a TracingOptions() instance.folder is the single most important value: it is the root every aspect's file path
is resolved against. In the real driver it comes from $react_html5_projectfolder, so
the same definition project can be woven into a fresh checkout each build.
tracingOptions (an instance of TracingOptions, see trackingOption.py) is the
in-memory representation of the loaded options/*.json; aspect modules read its fields
to parameterise advice.
A class — Html5AppAction (and siblings UWPAppAction, CastAppAction,
POCAppAction) — whose execute(extra) dispatches on self.job:
def execute(self, extra):
if self.job == 'auto_signin': self.auto_signin_aspect()
elif self.job == 'inject_logging':
self.copyRemoteLogFolder() # seed advisor library first
self.logging_aspect() # then weave
elif self.job == 'performance': self.performance_aspect()
elif self.job == 'prebuild-tracing':
self.afterProcess = "lint" # post-process: run ESLint on touched files
...
else:
sys.exit(2)Each *_aspect() method does the same three things:
def logging_aspect(self):
import html5App.aspects.logging # 1. import the definition module
self.extraAspects = 'remoteLog' # (opt) request import injection
codeInjector.aspectRunners.runAspectIn( # 2. hand its `aspects` to the engine
html5App.aspects.logging.aspects,
html5.config.folder, # target folder from config
self.onEachFile, # 3. post-process hook
self.context)runAspectIn calls onEachFile(aspect, file, content) after each successful weave. The
real driver uses it for two cross-cutting follow-ups:
- Import injection — when
self.extraAspects == 'remoteLog', it callsaspectHandlers.addRemoteLog(...)so any file that now referencesremoteLoggets the import added. - Linting — when
self.afterProcess == 'lint', it registers each touched.jsfile with anESLinthelper, run at the end viaexecuteESLint()to auto-fix formatting of the injected code.
The demo's hook is a no-op (return '', content) — post-processing is optional.
copyRemoteLogFolder()→fileutil.copyFolderIfEmpty(...)seeds the advisor library, then runsconfigure_remote_aspect().copyStaticRemoteLogFile()→shutil.copyfile(...)seedsremoteLogs.js, then weaves references to it.
To target a new kind of project, mirror the html5 package:
- Create
myapp/config.pywithnameandfolder(where the target lives). - Create
myapp/actions.pywith aMyAppActionclass whoseexecute(extra)maps jobs torunAspectIn(...)calls against your definition modules. - Register it in the entry point's
createAppAction(...):elif app == 'myapp': return MyAppAction(self.job)
- Put the engine and your definition project on
PYTHONPATH, then run with--app myapp --job <job>.
- Write/extend an aspect module in the definition project (e.g.
html5App/aspects/mytrace.py). - Add a method on the actions class that imports it and calls
runAspectIn(...). - Add an
elif self.job == 'my-trace':branch inexecute(...). - (Optional) add a shell wrapper — see Running the Injector.
The engine and the definition project are separate trees. Both must be importable, so
put both roots on PYTHONPATH:
export PYTHONPATH=.../code-injector:.../defintiomn-projectThis makes available:
codeInjector(engine) and the demohtml5/uwp/… app packages — fromcode-injector/;html5App(definition package:html5App.aspects.logging, etc.).
The driver imports definition modules by name (import html5App.aspects.logging), so if
PYTHONPATH is missing or wrong you get ModuleNotFoundError. This single variable is
what lets the same engine drive different definition projects.
cd .../code-injector
python . --app html5 --job sample_job --branch main --version 1.0.0 --env devpython . runs __main__.py. Arguments:
| Arg | Meaning |
|---|---|
--app |
Which app package to dispatch to (html5). |
--job |
Which job within that app (sample_job). |
--branch / --version / --env |
Build context used for aspect conditions. |
python .../aspects/pythons/index.py \
--app=html5 \
--job=inject_logging \
--env=perf \
--branch=acn_develop \
--version=14.2.0 \
--options=freeview \
--extra=Extra arguments:
| Arg | Meaning |
|---|---|
--options |
Name of an options/<name>.json file selecting a platform profile (see below). |
--extra |
Free-form extra flag passed to the action's execute(extra). |
aspects/pythons/html5/options/ holds one folder/file per platform (freeview,
tizen, ps4, ps5, amazon, xbox, youview, virgin, …). Example:
{
"platform": "freeview",
"context": { "versionSuffix": "" },
"logging": {
"enabled": true,
"request": "true",
"player": "true",
"storage": "false",
"performance": "true",
"observable": "false",
"org": "true"
},
"usePlusUser": false,
"httpVideo": "tests",
"platformDebug": "freeview_poc"
}When --options=freeview is passed, the driver:
- loads the JSON and populates
TracingOptions(html5/trackingOption.py), whose fields aspect modules read to parameterise advice (e.g. thelogging.*flags are.format()-substituted intoremote-log/config.js); - if
context.versionSuffixis set, appends it to the build version (or replaces it when prefixed withREPLACE:).
So one job behaves differently per platform without changing any aspect code — the JSON toggles features and supplies values.
In the production setup the Python entry point is wrapped by shell functions under
.../aspects/:
-
index.shsourceshtml5.sh,uwp.sh,cast.sh,poc.sh,deployment.sh. -
html5.shdefinesaspect_html5_inject_to_code, the common wrapper:aspect_html5_inject_to_code() { pythonresponseoutput=$($pythonCommandPath $aspectCorePythonScriptPath/index.py --app=html5 \ --job=$1 \ --env=$remoteLogEnv \ --branch=$currentHtmlBranchName \ --options=$2 \ --extra=$3 \ --version=$html5DeployedVersion 2>&1) if [[ $pythonresponseoutput == "" ]]; then echo "aspect successful in $react_html5_projectfolder" else # ANY output is treated as failure → abort the deploy onDeploymentJobFailed $3 exit 1 fi }
with thin job wrappers on top:
aspect_html5_logging,aspect_html5_performance,aspect_html5_auto_signin,aspect_html5_http_request,aspect_html5_watchlive,aspect_html5_prebuild_performance, etc.
| Variable | Used for |
|---|---|
BINROOTDIR |
Locating the aspects/*.sh scripts. |
pythonCommandPath |
The Python interpreter to run. |
aspectCorePythonScriptPath |
Path to pythons/ (where index.py lives). |
react_html5_projectfolder |
The target project folder (becomes config.folder). |
remoteLogEnv |
Maps to --env. |
currentHtmlBranchName |
Maps to --branch. |
html5DeployedVersion |
Maps to --version. |
Success = silence. The shell wrapper treats any stdout/stderr as failure. That is why the engine prints only on error and otherwise stays quiet — a successful injection produces no output, and the wrapper prints its own "successful" line.
Typical order during a deploy:
- Check out the main project into
$react_html5_projectfolder. - Export
PYTHONPATH(engine + definition project). - Run the relevant
aspect_html5_*shell functions (orindex.pydirectly) to weave in the required concerns for the target--env/--branch/--options. - Build the now-modified target project as normal.
The main project's own repository is never modified — only the checked-out working copy that gets built.
This walks through the self-contained demo shipped with the engine. It needs no
definition project — the aspects live in html5/aspects/sampleJob.py. The commands and
output below were run and verified.
code-injector/
├── __main__.py # entry point (python . dispatches here)
├── html5/
│ ├── config.py # name='html5', folder='./sampleCode'
│ ├── actions.py # Html5AppAction: job 'sample_job' → runAspectIn(...)
│ └── aspects/sampleJob.py # the aspect definitions
└── sampleCode/sample.js # the target file
html5/config.py:
name = 'html5'
folder = "./sampleCode"html5/aspects/sampleJob.py (trimmed):
aspects = [
{ "file": "sample.js",
"aspects": [
{ "pointcut": "let a = 5;",
"advice": "console.log('Assigned a to 5')",
"position": "after" },
{ "pointcut": "let b = 10;",
"advice": "console.log('About to assign b to 10')",
"position": "before" },
]
},
]html5/actions.py maps the job to an engine run:
def __sample_job_aspect(self):
import html5.aspects.sampleJob
codeInjector.aspectRunners.runAspectIn(
html5.aspects.sampleJob.aspects, html5.config.folder, self.__on_each_file, self.context)
def execute(self):
if self.job == 'sample_job':
self.__sample_job_aspect()cd .../code-injector
export PYTHONPATH=.../code-injector
python3 . --app html5 --job sample_job --branch main --version 1.0.0 --env devThe process exits 0 and prints nothing on success (recall: success = silence).
// This code will be modified as configured in html5/aspects/sampleJob.py
let a = 5
let b = 10// This code will be modified as configured in html5/aspects/sampleJob.py
let a = 5
console.log('Assigned a to 5')
console.log('About to assign b to 10')
let b = 10Note how each injected line lands on its own line — ContentRange.getSeparators
inserted the needed \n without adding blank-line noise.
__main__.pyparsed the args and calledcontext.createApp('html5', 'main', '1.0.0', 'dev').Html5AppAction('sample_job').execute()dispatched to__sample_job_aspect().- That imported
html5.aspects.sampleJoband calledrunAspectIn(aspects, './sampleCode', onEachFile, context). - The engine resolved
./sampleCode/sample.js, read it, and for each inner aspect:checkAspectConditionpassed (no conditions, advice not yet present);handleAspectfound the pointcut and inserted the advice (afterlet a = 5;,beforelet b = 10;).
- The file was written back.
Run the same command again. Because skip-if-advice-found defaults to True, the
engine sees its own console.log(...) advice already present and skips both aspects —
the file is unchanged, no duplicate logs. (To prove it to yourself, run twice and diff.)
The demo edits the file in place. To get the original back:
cd .../code-injector
git checkout -- sampleCode/sample.js(In a real build the target is a throwaway checkout, so in-place editing is fine and the source repo is never touched — see Where this fits in a build.)
| Demo | Real |
|---|---|
Aspects in html5/aspects/sampleJob.py (same repo) |
Aspects in .../html5App/aspects/*.py (separate project) |
folder = "./sampleCode" |
folder = os.environ['react_html5_projectfolder'] (build checkout) |
One job sample_job |
Many jobs: inject_logging, performance, auto_signin, … |
No-op onEachFile |
Lint + remoteLog import injection |
python . by hand |
aspect_html5_* shell functions inside the deploy pipeline |
You now have the full picture: the engine is generic, the definition project supplies the rules and payload, and the driver orchestrates them per build context and platform. You may also go further by reading through the opening build story
This lab supports the first story in the tutorial. It modifies only the local
lab/app copy.
./scripts/reset.sh
./scripts/run.sh
sed -n '1,120p' app/src/app.js