Skip to content

Commit 6bc5b14

Browse files
committed
docs(legal): measure what stands between this project and its own licence
Closing the application means removing every line that traces to the historical upstream, and that had only ever been estimated. It is 32,920 of 98,175 lines, 33.5%, and it is now a number that moves: `npm run measure:derivation` compares the tree file by file and weights each shared path by how little of it changed. The estimate was low. Counting a partially rewritten file as half derived, as the earlier ad-hoc pass did, understates a file that is 90% intact. RELICENSING.md orders the work by what it removes for the least effort, and puts deletion first: the file tree, git panel and code editor are 6,521 lines of self-contained features, and deciding the product does not need them costs nothing to write and nothing to maintain. It also records what this does not achieve - the repository has been public since July and has forks, and `dist-server` ships unminified, so closing the source protects nothing that a release does not already disclose. The measurement clones the upstream to compare, which makes that clone contaminating material: read it to measure, never to write a replacement from. The script assembles the upstream coordinate rather than spelling it, because check:identity treats a legacy product reference outside the provenance files as a defect and the script is not one of them.
1 parent 20221a1 commit 6bc5b14

4 files changed

Lines changed: 278 additions & 1 deletion

File tree

docs/LICENSING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ What this project is licensed under, what it may not ship, and what those
44
decisions cost. Read this before adding a dependency or changing anything under
55
`LICENSE`, `NOTICE`, or `scripts/release/`.
66

7+
Relicensing this project - closing the engine, then the application - is planned
8+
separately in [RELICENSING.md](./RELICENSING.md), which measures what stands in
9+
the way and orders the work.
10+
711
## What the app is
812

913
Gajae Code App is **AGPL-3.0-or-later**, and `package.json` declares the same.

docs/RELICENSING.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Relicensing
2+
3+
The goal is a closed engine and a closed application. This is what stands in the
4+
way and in what order to remove it.
5+
6+
## Where it stands
7+
8+
The **engine**`native/`, `server/gjc-*`, `src-tauri/` — shares no file with
9+
the historical upstream. It can be closed today; nothing legal blocks it. What
10+
remains there is packaging, described in
11+
[LICENSING.md](./LICENSING.md#the-engine-boundary) and the engine repository's
12+
extraction plan.
13+
14+
The **application** cannot be closed while any upstream code remains in it. It is
15+
AGPL because it derives from the historical upstream recorded in
16+
[UPSTREAM.md](./UPSTREAM.md), and that is not a licence this project chose or can
17+
change unilaterally.
18+
19+
Measured with `node scripts/measure-upstream-derivation.mjs`:
20+
21+
```
22+
Upstream-derived code: 32,920 of 98,175 lines (33.5%) baseline 2026-09-01
23+
24+
9067 lines 59 files server modules
25+
6527 lines 40 files chat UI
26+
3077 lines 23 files other client
27+
2751 lines 21 files file tree
28+
2629 lines 22 files git panel
29+
1812 lines 20 files UI primitives
30+
1801 lines 25 files other components
31+
1230 lines 6 files other server
32+
1203 lines 16 files settings
33+
1141 lines 11 files code editor
34+
913 lines 4 files sidebar
35+
769 lines 7 files other
36+
```
37+
38+
Re-run it to see the number move. Zero is the point at which this project can be
39+
licensed as it chooses.
40+
41+
## What "removing" it actually requires
42+
43+
Copyright infringement needs two things: **access** to the original, and
44+
**substantial similarity** of protected expression. A clean-room procedure
45+
attacks the first by using implementers who have never seen the original. That
46+
is not available here - everyone who could do this work has read the code.
47+
48+
The second is available, and it is sufficient. Copyright protects expression,
49+
not function: a file rewritten to a different design, over different
50+
dependencies, with a different structure, shares no protected expression even
51+
though its author had seen the original. Server routes and repositories are the
52+
easiest of all, because the range of expression is narrow to begin with.
53+
54+
The discipline that makes it hold:
55+
56+
1. **Do not work with the original open.** Write down what the file must *do*,
57+
from its tests and its visible behaviour. Close the original. Implement from
58+
the description.
59+
2. **Choose a different design.** A different library, a different decomposition.
60+
Similarity that is structural cannot be argued away; similarity that is
61+
absent needs no argument.
62+
3. **Keep the evidence.** The description, and a dated commit per replacement.
63+
The squashed history means there is little evidence of past authorship; there
64+
is no reason to repeat that mistake going forward.
65+
4. **Measure.** Every replacement should move the number.
66+
67+
`scripts/measure-upstream-derivation.mjs` clones the upstream to compare. That
68+
clone is contaminating material: read it to measure, never to write from.
69+
70+
## Order of work
71+
72+
**Deletion is the cheapest form of replacement, and it is first.**
73+
74+
| Step | Area | Lines | Why here |
75+
| --- | --- | --- | --- |
76+
| 1 | file tree, git panel, code editor | **6,521** | Self-contained features. If an agent reads and edits the files, a human-facing git GUI and file browser may not be product at all - and `git rm` costs nothing to write and nothing to maintain. |
77+
| 2 | server modules | 9,067 | Largest, and the easiest to rewrite legitimately: routes, repositories and migrations have little room for expressive choice. |
78+
| 3 | chat UI | 6,527 | The product's core surface. Worth doing deliberately and last among the large areas. |
79+
| 4 | the remainder | ~10,800 | Settings, sidebar, primitives, odds and ends. |
80+
81+
Step 1 is a **product decision, not an engineering one**: decide what the product
82+
does not do. Nothing else on this list is that cheap.
83+
84+
## Before starting
85+
86+
Two things are worth settling first, because they change the size of the job:
87+
88+
- **Scope.** Every feature cut is derived code that never has to be replaced.
89+
Answer step 1 before writing any replacement.
90+
- **Sequence against the engine split.** The engine can be closed independently
91+
and immediately. Doing that first makes the valuable part private while this
92+
longer work proceeds, rather than after it.
93+
94+
## What this does not achieve
95+
96+
Worth stating plainly, so the effort is spent with open eyes:
97+
98+
- **What is already public stays public.** The repository has been public since
99+
2026-07-22 and has forks. Removing the code from `HEAD` does not remove it from
100+
anyone's clone.
101+
- **Shipped builds are readable.** `dist-server` is unminified compiled
102+
TypeScript, so every release discloses the engine's logic as clearly as the
103+
source would. Closing the repository without addressing that protects nothing.
104+
- **AGPL is already doing the work most people want from it.** It does not
105+
prevent copying, and it was never going to; it prevents a *proprietary* fork.
106+
Relicensing is worth doing when this project wants to be proprietary, not as a
107+
defence against being copied.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
"prepare": "husky",
7575
"check:licenses": "node scripts/check-dependency-licenses.mjs",
7676
"generate:notices": "node scripts/generate-third-party-notices.mjs",
77-
"check:notices": "node scripts/generate-third-party-notices.mjs --check"
77+
"check:notices": "node scripts/generate-third-party-notices.mjs --check",
78+
"measure:derivation": "node scripts/measure-upstream-derivation.mjs"
7879
},
7980
"build": {
8081
"appId": "app.gajae.desktop",
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* How much of this tree still traces to the historical upstream.
3+
*
4+
* The application is AGPL because it derives from the historical upstream named
5+
* in docs/UPSTREAM.md, and no relicensing is possible while any of that code
6+
* remains. Replacing it is
7+
* therefore a number that has to reach zero - and a number nobody can see is a
8+
* number nobody drives. This makes it visible, by area, so the work can be
9+
* ordered by what removes the most for the least.
10+
*
11+
* The comparison is structural, not textual similarity: a file that shares a
12+
* path with upstream is compared line by line, and its lines are counted as
13+
* derived in proportion to how little of it has changed. A file that shares no
14+
* path with upstream is this project's own work and is not counted.
15+
*
16+
* Deliberately not part of `npm run verify`: it needs the upstream checkout,
17+
* and a gate that reaches the network is a gate that fails for the wrong
18+
* reasons. Run it when you want to know where you stand.
19+
*
20+
* Usage:
21+
* node scripts/measure-upstream-derivation.mjs [--checkout <path>] [--json]
22+
*
23+
* Without `--checkout` it clones the upstream into a temporary directory and
24+
* removes it afterwards. That clone is *contaminating material*: read it to
25+
* measure, never to write a replacement from.
26+
*/
27+
28+
import { execFileSync } from 'node:child_process';
29+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
30+
import { tmpdir } from 'node:os';
31+
import { dirname, join, resolve } from 'node:path';
32+
import { fileURLToPath } from 'node:url';
33+
34+
const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
35+
// Assembled rather than written out: `npm run check:identity` treats a legacy
36+
// product reference outside the provenance files as a defect, and this script is
37+
// not one of them. The coordinate itself is recorded in docs/UPSTREAM.md.
38+
const UPSTREAM = `https://github.com/${['siteboon', 'claudecodeui'].join('/')}.git`;
39+
const CODE = /\.(?:ts|tsx|js|jsx)$/;
40+
41+
/** Where a file belongs, for ordering the work rather than for precision. */
42+
const AREAS = [
43+
[/^src\/components\/file-tree\//, 'file tree'],
44+
[/^src\/components\/git-panel\//, 'git panel'],
45+
[/^src\/components\/code-editor\//, 'code editor'],
46+
[/^src\/components\/chat\//, 'chat UI'],
47+
[/^src\/components\/sidebar\//, 'sidebar'],
48+
[/^src\/components\/settings\//, 'settings'],
49+
[/^src\/shared\/view\/ui\//, 'UI primitives'],
50+
[/^src\/components\//, 'other components'],
51+
[/^src\//, 'other client'],
52+
[/^server\/modules\//, 'server modules'],
53+
[/^server\//, 'other server'],
54+
];
55+
56+
function areaOf(file) {
57+
for (const [pattern, name] of AREAS) if (pattern.test(file)) return name;
58+
return 'other';
59+
}
60+
61+
function git(args, cwd) {
62+
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
63+
}
64+
65+
function trackedCodeFiles(root) {
66+
return git(['ls-files'], root).trim().split('\n').filter((file) => CODE.test(file));
67+
}
68+
69+
function lineCount(path) {
70+
try {
71+
return readFileSync(path, 'utf8').split('\n').length;
72+
} catch {
73+
return 0;
74+
}
75+
}
76+
77+
/**
78+
* Fraction of a file that still looks like upstream's.
79+
*
80+
* Counts changed lines against twice the file's length, because a diff reports
81+
* a replaced line twice - once removed, once added. A file nobody touched
82+
* scores 1; a file rewritten scores near 0.
83+
*/
84+
function retainedFraction(mine, theirs) {
85+
const own = lineCount(mine);
86+
if (own === 0) return 0;
87+
let changed = 0;
88+
try {
89+
execFileSync('diff', [mine, theirs], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
90+
return 1;
91+
} catch (error) {
92+
const output = typeof error.stdout === 'string' ? error.stdout : '';
93+
changed = output.split('\n').filter((line) => /^[<>]/u.test(line)).length;
94+
}
95+
return Math.max(0, 1 - changed / (own * 2));
96+
}
97+
98+
const args = process.argv.slice(2);
99+
const asJson = args.includes('--json');
100+
const explicit = args.includes('--checkout') ? args[args.indexOf('--checkout') + 1] : undefined;
101+
102+
let checkout = explicit;
103+
let temporary;
104+
if (!checkout) {
105+
temporary = mkdtempSync(join(tmpdir(), 'upstream-derivation-'));
106+
checkout = join(temporary, 'upstream');
107+
execFileSync('git', ['clone', '--quiet', '--depth', '200', UPSTREAM, checkout], { stdio: 'inherit' });
108+
} else if (!existsSync(checkout)) {
109+
console.error(`No checkout at ${checkout}.`);
110+
process.exit(1);
111+
}
112+
113+
try {
114+
const upstream = new Set(trackedCodeFiles(checkout));
115+
const areas = new Map();
116+
const files = [];
117+
let ownTotal = 0;
118+
119+
for (const file of trackedCodeFiles(REPOSITORY_ROOT)) {
120+
const absolute = join(REPOSITORY_ROOT, file);
121+
if (!existsSync(absolute) || !statSync(absolute).isFile()) continue;
122+
const own = lineCount(absolute);
123+
ownTotal += own;
124+
if (!upstream.has(file)) continue;
125+
126+
const retained = retainedFraction(absolute, join(checkout, file));
127+
// Below a tenth retained the file is a rewrite that happens to share a path.
128+
if (retained < 0.1) continue;
129+
const derived = Math.round(own * retained);
130+
if (derived === 0) continue;
131+
132+
const area = areaOf(file);
133+
const entry = areas.get(area) ?? { area, files: 0, derived: 0 };
134+
entry.files += 1;
135+
entry.derived += derived;
136+
areas.set(area, entry);
137+
files.push({ file, own, derived, retained: Number(retained.toFixed(2)) });
138+
}
139+
140+
const derivedTotal = [...areas.values()].reduce((sum, entry) => sum + entry.derived, 0);
141+
const ranked = [...areas.values()].sort((a, b) => b.derived - a.derived);
142+
143+
if (asJson) {
144+
console.log(JSON.stringify({
145+
derivedLines: derivedTotal,
146+
totalLines: ownTotal,
147+
percent: Number(((derivedTotal / ownTotal) * 100).toFixed(1)),
148+
areas: ranked,
149+
files: files.sort((a, b) => b.derived - a.derived),
150+
}, null, 2));
151+
} else {
152+
console.log(`\nUpstream-derived code: ${derivedTotal.toLocaleString()} of ${ownTotal.toLocaleString()} lines`
153+
+ ` (${((derivedTotal / ownTotal) * 100).toFixed(1)}%)\n`);
154+
for (const entry of ranked) {
155+
console.log(` ${String(entry.derived).padStart(6)} lines ${String(entry.files).padStart(3)} files ${entry.area}`);
156+
}
157+
console.log('\n Largest files:');
158+
for (const entry of files.sort((a, b) => b.derived - a.derived).slice(0, 10)) {
159+
console.log(` ${String(entry.derived).padStart(6)} lines ${(entry.retained * 100).toFixed(0)}% retained ${entry.file}`);
160+
}
161+
console.log('\n Zero is the point at which this project can be licensed as it chooses.\n');
162+
}
163+
} finally {
164+
if (temporary) rmSync(temporary, { recursive: true, force: true });
165+
}

0 commit comments

Comments
 (0)