@stonepandastudio/cairn 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -0
- package/bin/cairn.js +63 -0
- package/lib/config.js +146 -0
- package/lib/doctor/diff.js +127 -0
- package/lib/doctor/index.js +517 -0
- package/lib/doctor/normalize.js +83 -0
- package/lib/doctor/scan.js +84 -0
- package/lib/index.js +20 -0
- package/lib/init.js +186 -0
- package/lib/manifest.js +120 -0
- package/lib/paint.js +41 -0
- package/lib/tracker/cli.js +240 -0
- package/lib/tracker/index.js +51 -0
- package/lib/tracker/jira-server.js +291 -0
- package/lib/tracker/none.js +55 -0
- package/package.json +43 -0
- package/schema.json +71 -0
- package/templates/shims/jira.js +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# @stonepandastudio/cairn
|
|
2
|
+
|
|
3
|
+
Shared AI workflow scaffolding for Stone Panda repos.
|
|
4
|
+
|
|
5
|
+
Five repos — `glossr-api`, `glossr-cli`, `glossr-frontend`, `snap-proof/backend`,
|
|
6
|
+
`snap-proof/frontend` — each carry an `ai/` folder and a `.claude/commands/` folder
|
|
7
|
+
holding the same workflow: brief → description → plan → execute → review → QA, with
|
|
8
|
+
the steps mirrored into Jira. They were copied between repos by hand and drifted.
|
|
9
|
+
|
|
10
|
+
A cairn is a stack of stones left to mark a trail. That is what this does: it leaves
|
|
11
|
+
markers — a manifest of what it generated, and a report of where the copies stopped
|
|
12
|
+
agreeing.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i -D @stonepandastudio/cairn
|
|
16
|
+
npx cairn init --stack backend --tracker jira-server --project-key PROOF --story-type Task
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What v1 does
|
|
20
|
+
|
|
21
|
+
| Command | |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `cairn tracker <cmd>` | Issue tracker client. Replaces the four copied `ai/scripts/jira.js` files |
|
|
24
|
+
| `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
|
|
25
|
+
| `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
|
|
26
|
+
|
|
27
|
+
Template rendering — generating the command stubs, `WORKFLOW.md` and the agent role
|
|
28
|
+
contracts from one workflow declaration — is v2, and deliberately not in this release.
|
|
29
|
+
v1 swaps the Jira client under five repos; bundling prompt changes into the same
|
|
30
|
+
release would make any regression impossible to attribute.
|
|
31
|
+
|
|
32
|
+
## The two folders
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
repo/
|
|
36
|
+
cairn.config.json you edit this. cairn only ever reads it.
|
|
37
|
+
_cairn/ cairn writes this. nobody hand-edits it.
|
|
38
|
+
manifest.json what was generated, from which template, at which hash
|
|
39
|
+
scripts/jira.js vendored shim
|
|
40
|
+
ai/ project-owned. cairn seeds it at init and never touches it again.
|
|
41
|
+
contexts/ progress/ tasks/ infrastructure/
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The boundary is the whole design. Hand-edited config living inside a generated
|
|
45
|
+
directory is how generated directories acquire state nobody dares regenerate.
|
|
46
|
+
|
|
47
|
+
Generated files are **committed**, not gitignored. Claude Code discovers
|
|
48
|
+
`.claude/commands/` and agent docs from disk, and a prompt change should show up in
|
|
49
|
+
`git diff` like any other change.
|
|
50
|
+
|
|
51
|
+
## Tracker
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cairn tracker list-statuses PROOF
|
|
55
|
+
cairn tracker create-task "Protected image fetch API"
|
|
56
|
+
cairn tracker create-subtask PROOF-217 "Step 1: signed URL issuing"
|
|
57
|
+
cairn tracker set-status PROOF-218 "In Progress"
|
|
58
|
+
cairn tracker append-description PROOF-217 ./ai/tasks/PROOF-217/brief-step-1.md --markup
|
|
59
|
+
cairn tracker close-story GLO-42 # cascades through subtasks first
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Providers are `jira-server` and `none`. `none` is a real provider, not an error case:
|
|
63
|
+
`glossr-cli` has no tracker, and the workflow steps become no-ops rather than every
|
|
64
|
+
command stub needing an "if this repo has Jira" branch written in prose.
|
|
65
|
+
|
|
66
|
+
Three things the four copied scripts did that this does not:
|
|
67
|
+
|
|
68
|
+
- **`process.exit` from library code.** Failures throw `TrackerError`; the CLI layer
|
|
69
|
+
chooses the exit code. That is what makes the client callable from a workflow runner.
|
|
70
|
+
- **Fail on a re-run.** Transitioning to a status the issue already holds is a
|
|
71
|
+
successful no-op. `NoTransitionError` exits 0 — re-running a step must be safe.
|
|
72
|
+
- **Ask an agent to convert markup by hand.** `ai/JIRA_MARKUP.md` was a prose document
|
|
73
|
+
in three variants explaining markdown → Jira wiki conversion. It is `toMarkup()` now,
|
|
74
|
+
under test, reachable via `--markup`.
|
|
75
|
+
|
|
76
|
+
Credentials come from the repo's `.env` (`JIRA_BASE_URL`, `JIRA_USER`, `JIRA_PASSWORD`).
|
|
77
|
+
Jira Server 8.5.1 predates PAT support, so auth is Basic. Anything already in the
|
|
78
|
+
environment wins over the file.
|
|
79
|
+
|
|
80
|
+
### Legacy command names
|
|
81
|
+
|
|
82
|
+
`create-task`, `create-story`, `close-story` and friends are all still accepted, and
|
|
83
|
+
output formats are byte-for-byte what the old scripts printed. Roughly forty prose
|
|
84
|
+
references across five repos name these strings, and the agents parse the output.
|
|
85
|
+
They get renamed in the v2 pass that regenerates those documents.
|
|
86
|
+
|
|
87
|
+
## Doctor
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
cairn doctor # full table
|
|
91
|
+
cairn doctor --diff ai/WORKFLOW.md # hunks for one file across repos
|
|
92
|
+
cairn doctor --json
|
|
93
|
+
cairn doctor --strict # exit 1 on drift or a hand-edited generated file
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
It reports two independent axes, and keeping them apart is the point:
|
|
97
|
+
|
|
98
|
+
**Drift** — cross-repo. Do the copies still agree? Raw comparison is noise, so each
|
|
99
|
+
file is normalized first: BOM and CRLF stripped, repo paths and names folded,
|
|
100
|
+
`GLO-18`/`PROOF-210` folded to `{{ISSUE}}`, and declared vars folded to
|
|
101
|
+
`{{placeholder}}`. What survives is drift no template variable explains. Files are
|
|
102
|
+
then clustered by normalized content; the largest cluster becomes the reference.
|
|
103
|
+
|
|
104
|
+
| Status | Meaning |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `IDENTICAL` | byte-identical everywhere |
|
|
107
|
+
| `COSMETIC` | differs only in declared vars — extract as a template, zero merge cost |
|
|
108
|
+
| `DRIFT` | two or more genuinely different variants, with a similarity score |
|
|
109
|
+
| `MISSING` | some repos in the cohort lack it |
|
|
110
|
+
| `STRANDED` | exactly one repo has it — promotion candidate |
|
|
111
|
+
|
|
112
|
+
**Managed** — per-repo, read from `_cairn/manifest.json`. Does what cairn wrote still
|
|
113
|
+
match what is on disk?
|
|
114
|
+
|
|
115
|
+
| State | Meaning |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `MANAGED` | on-disk hash matches what cairn wrote |
|
|
118
|
+
| `MODIFIED` | someone hand-edited a generated file |
|
|
119
|
+
| `OUTDATED` | cairn has newer content for it |
|
|
120
|
+
| `DELETED` | it is gone; `sync` will restore it |
|
|
121
|
+
|
|
122
|
+
A file can be perfectly in sync across repos while being hand-edited away from its
|
|
123
|
+
template, and vice versa. `MODIFIED` outranks `OUTDATED` in the report because
|
|
124
|
+
overwriting a hand edit is the destructive outcome.
|
|
125
|
+
|
|
126
|
+
### Workspace config (`repos.json`)
|
|
127
|
+
|
|
128
|
+
- `repos[]` — name, path, stack. Per-repo settings live in each repo's own
|
|
129
|
+
`cairn.config.json`; the inline `vars` blocks are a fallback used only until a repo
|
|
130
|
+
has been through `cairn init`, so migration can happen one repo at a time.
|
|
131
|
+
- `pathAliases` — local → canonical. `glossr-cli` calls its stubs `execute-tests.md`
|
|
132
|
+
and `review-code.md`; aliasing folds them onto the cohort's names instead of
|
|
133
|
+
reporting one `MISSING` and one `STRANDED` for what is the same file.
|
|
134
|
+
- `shared[]` — glob rules. `cohort: "all"` compares every repo; `cohort: "stack"`
|
|
135
|
+
compares backend-with-backend. `requires: { tracker: true }` skips repos with no
|
|
136
|
+
tracker. A repo with no config has an *unknown* tracker, not an absent one, and is
|
|
137
|
+
never skipped on that basis.
|
|
138
|
+
- Anything not matched by a rule — `ai/contexts/`, `ai/progress/`, `ai/tasks/`,
|
|
139
|
+
`DATABASE_SCHEMA.md` — is per-project by design and never inspected.
|
|
140
|
+
`ai/INITIAL_PROMPT.md` is explicitly excluded: four variants at 0% shared content
|
|
141
|
+
make it a per-project document, not a drifted template.
|
|
142
|
+
|
|
143
|
+
## Layout
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
bin/cairn.js subcommand router
|
|
147
|
+
lib/config.js cairn.config.json + .env loading
|
|
148
|
+
lib/manifest.js generated-file hashing and state
|
|
149
|
+
lib/paint.js ANSI + table rendering
|
|
150
|
+
lib/tracker/ index (registry), cli, jira-server, none
|
|
151
|
+
lib/doctor/ index (analysis + report), scan, normalize, diff
|
|
152
|
+
lib/init.js cairn init
|
|
153
|
+
templates/shims/ vendored shim source
|
|
154
|
+
schema.json JSON Schema for cairn.config.json
|
|
155
|
+
test/run.js dependency-free test runner
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
No runtime dependencies. Node >= 18. `npm test` runs 34 tests.
|
package/bin/cairn.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// cairn — shared AI workflow scaffolding for Stone Panda repos.
|
|
5
|
+
//
|
|
6
|
+
// v1 surface:
|
|
7
|
+
// cairn tracker <cmd> issue tracker client (replaces the copied ai/scripts/jira.js)
|
|
8
|
+
// cairn doctor cross-repo drift report
|
|
9
|
+
// cairn init make a repo cairn-managed
|
|
10
|
+
//
|
|
11
|
+
// Template rendering (command stubs, WORKFLOW.md, agent role contracts) is v2 and
|
|
12
|
+
// deliberately absent: this release swaps the Jira client under five repos, and
|
|
13
|
+
// mixing that with prompt changes would make any regression unattributable.
|
|
14
|
+
|
|
15
|
+
const HELP = `cairn — shared AI workflow scaffolding
|
|
16
|
+
|
|
17
|
+
Usage: cairn <command> [options]
|
|
18
|
+
|
|
19
|
+
tracker <cmd> [...] issue tracker client (cairn tracker --help)
|
|
20
|
+
doctor [options] cross-repo drift report (cairn doctor --help)
|
|
21
|
+
init [options] make a repo cairn-managed (cairn init --help)
|
|
22
|
+
version print version
|
|
23
|
+
|
|
24
|
+
Run \`cairn <command> --help\` for command options.
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
function main() {
|
|
28
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
29
|
+
|
|
30
|
+
switch (command) {
|
|
31
|
+
case 'tracker':
|
|
32
|
+
require('../lib/tracker/cli')(rest);
|
|
33
|
+
return;
|
|
34
|
+
|
|
35
|
+
case 'doctor':
|
|
36
|
+
process.exitCode = require('../lib/doctor').main(rest);
|
|
37
|
+
return;
|
|
38
|
+
|
|
39
|
+
case 'init':
|
|
40
|
+
process.exitCode = require('../lib/init').main(rest);
|
|
41
|
+
return;
|
|
42
|
+
|
|
43
|
+
case 'version':
|
|
44
|
+
case '--version':
|
|
45
|
+
case '-v':
|
|
46
|
+
console.log(require('../package.json').version);
|
|
47
|
+
return;
|
|
48
|
+
|
|
49
|
+
case undefined:
|
|
50
|
+
case 'help':
|
|
51
|
+
case '--help':
|
|
52
|
+
case '-h':
|
|
53
|
+
process.stdout.write(HELP);
|
|
54
|
+
return;
|
|
55
|
+
|
|
56
|
+
default:
|
|
57
|
+
console.error(`Unknown command: ${command}`);
|
|
58
|
+
process.stderr.write(HELP);
|
|
59
|
+
process.exitCode = 2;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
main();
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const CONFIG_NAME = 'cairn.config.json';
|
|
7
|
+
const CAIRN_DIR = '_cairn';
|
|
8
|
+
|
|
9
|
+
// Boundary rule for the whole framework:
|
|
10
|
+
// cairn.config.json — hand-edited, lives at the repo root, cairn only reads it
|
|
11
|
+
// _cairn/ — cairn writes it, nobody hand-edits it
|
|
12
|
+
// Keeping the editable file out of the generated directory is what stops the
|
|
13
|
+
// generated directory from slowly acquiring hand-maintained state.
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
stack: 'unknown',
|
|
17
|
+
tracker: { provider: 'none' },
|
|
18
|
+
agents: {},
|
|
19
|
+
vars: {},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
class ConfigError extends Error {}
|
|
23
|
+
|
|
24
|
+
function configPath(repoPath) {
|
|
25
|
+
return path.join(repoPath, CONFIG_NAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cairnDir(repoPath) {
|
|
29
|
+
return path.join(repoPath, CAIRN_DIR);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Parse a dotenv-ish file. Deliberately minimal — no export keyword, no
|
|
33
|
+
// interpolation, no multiline values. The four repos' .env files are flat.
|
|
34
|
+
function parseEnv(text) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const line of text.split('\n')) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
39
|
+
const eq = trimmed.indexOf('=');
|
|
40
|
+
if (eq === -1) continue;
|
|
41
|
+
const key = trimmed.slice(0, eq).trim();
|
|
42
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
43
|
+
if (
|
|
44
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
45
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
46
|
+
) {
|
|
47
|
+
value = value.slice(1, -1);
|
|
48
|
+
}
|
|
49
|
+
out[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Load .env into process.env without clobbering anything already set, so CI
|
|
55
|
+
// secrets and shell exports always win over the committed-adjacent file.
|
|
56
|
+
function loadEnv(repoPath, relEnv = '.env') {
|
|
57
|
+
const envPath = path.resolve(repoPath, relEnv);
|
|
58
|
+
if (!fs.existsSync(envPath)) return {};
|
|
59
|
+
const parsed = parseEnv(fs.readFileSync(envPath, 'utf8'));
|
|
60
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
61
|
+
if (process.env[key] === undefined) process.env[key] = value;
|
|
62
|
+
}
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeTracker(tracker) {
|
|
67
|
+
const t = { ...(tracker || {}) };
|
|
68
|
+
t.provider = t.provider || 'none';
|
|
69
|
+
t.issueTypes = { story: 'Story', subtask: 'Sub-task', ...(t.issueTypes || {}) };
|
|
70
|
+
t.statuses = { todo: 'To Do', inProgress: 'In Progress', done: 'Done', ...(t.statuses || {}) };
|
|
71
|
+
t.env = t.env || '.env';
|
|
72
|
+
return t;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validate(cfg, source) {
|
|
76
|
+
if (cfg.tracker.provider !== 'none' && !cfg.tracker.projectKey) {
|
|
77
|
+
throw new ConfigError(`${source}: tracker.provider is "${cfg.tracker.provider}" but tracker.projectKey is missing`);
|
|
78
|
+
}
|
|
79
|
+
if (cfg.aiKit) {
|
|
80
|
+
throw new ConfigError(`${source}: "aiKit" is the pre-rename key — rename it to "cairn"`);
|
|
81
|
+
}
|
|
82
|
+
return cfg;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Read a repo's config. `fallbackVars` lets the doctor keep working against
|
|
86
|
+
// repos that have not been migrated yet: repos.json can still carry an inline
|
|
87
|
+
// `vars` block, and it is used only when the repo has no cairn.config.json.
|
|
88
|
+
function loadRepoConfig(repoPath, { fallbackVars = null, required = false } = {}) {
|
|
89
|
+
const file = configPath(repoPath);
|
|
90
|
+
if (!fs.existsSync(file)) {
|
|
91
|
+
if (required) {
|
|
92
|
+
throw new ConfigError(`No ${CONFIG_NAME} in ${repoPath} — run \`cairn init\` there first`);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
...DEFAULTS,
|
|
96
|
+
tracker: normalizeTracker(null),
|
|
97
|
+
vars: fallbackVars || {},
|
|
98
|
+
unmanaged: true,
|
|
99
|
+
path: repoPath,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let raw;
|
|
104
|
+
try {
|
|
105
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new ConfigError(`${file}: invalid JSON — ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cfg = {
|
|
111
|
+
...DEFAULTS,
|
|
112
|
+
...raw,
|
|
113
|
+
tracker: normalizeTracker(raw.tracker),
|
|
114
|
+
agents: raw.agents || {},
|
|
115
|
+
vars: raw.vars || fallbackVars || {},
|
|
116
|
+
unmanaged: false,
|
|
117
|
+
path: repoPath,
|
|
118
|
+
configFile: file,
|
|
119
|
+
};
|
|
120
|
+
return validate(cfg, file);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Walk up from cwd looking for cairn.config.json. Lets `cairn tracker …` be run
|
|
124
|
+
// from anywhere inside a repo, which matters because the agents invoke it from
|
|
125
|
+
// wherever their session happens to be.
|
|
126
|
+
function findRepoRoot(start = process.cwd()) {
|
|
127
|
+
let dir = path.resolve(start);
|
|
128
|
+
for (;;) {
|
|
129
|
+
if (fs.existsSync(path.join(dir, CONFIG_NAME))) return dir;
|
|
130
|
+
const parent = path.dirname(dir);
|
|
131
|
+
if (parent === dir) return null;
|
|
132
|
+
dir = parent;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
CONFIG_NAME,
|
|
138
|
+
CAIRN_DIR,
|
|
139
|
+
ConfigError,
|
|
140
|
+
cairnDir,
|
|
141
|
+
configPath,
|
|
142
|
+
findRepoRoot,
|
|
143
|
+
loadEnv,
|
|
144
|
+
loadRepoConfig,
|
|
145
|
+
parseEnv,
|
|
146
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Line diff utilities with no external dependencies.
|
|
4
|
+
//
|
|
5
|
+
// Full LCS backtracking is O(n*m) in both time and memory, which is fine for the
|
|
6
|
+
// agent/command markdown files this tool compares (a few hundred lines each) but
|
|
7
|
+
// not for anything pathological. Past MAX_CELLS we fall back to a multiset
|
|
8
|
+
// estimate that gets the counts right without producing usable hunks.
|
|
9
|
+
|
|
10
|
+
const MAX_CELLS = 4_000_000;
|
|
11
|
+
|
|
12
|
+
function splitLines(text) {
|
|
13
|
+
const lines = text.split('\n');
|
|
14
|
+
// A trailing newline yields a final empty element that is not a real line.
|
|
15
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
16
|
+
return lines;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Returns an array of ['=' | '-' | '+', line] ops turning `a` into `b`,
|
|
20
|
+
// or null when the input is too large for the LCS table.
|
|
21
|
+
function diffOps(a, b) {
|
|
22
|
+
const n = a.length;
|
|
23
|
+
const m = b.length;
|
|
24
|
+
if ((n + 1) * (m + 1) > MAX_CELLS) return null;
|
|
25
|
+
|
|
26
|
+
const w = m + 1;
|
|
27
|
+
const dp = new Int32Array((n + 1) * w);
|
|
28
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
29
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
30
|
+
dp[i * w + j] =
|
|
31
|
+
a[i] === b[j]
|
|
32
|
+
? dp[(i + 1) * w + (j + 1)] + 1
|
|
33
|
+
: Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ops = [];
|
|
38
|
+
let i = 0;
|
|
39
|
+
let j = 0;
|
|
40
|
+
while (i < n && j < m) {
|
|
41
|
+
if (a[i] === b[j]) {
|
|
42
|
+
ops.push(['=', a[i]]);
|
|
43
|
+
i++;
|
|
44
|
+
j++;
|
|
45
|
+
} else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)]) {
|
|
46
|
+
ops.push(['-', a[i]]);
|
|
47
|
+
i++;
|
|
48
|
+
} else {
|
|
49
|
+
ops.push(['+', b[j]]);
|
|
50
|
+
j++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
while (i < n) ops.push(['-', a[i++]]);
|
|
54
|
+
while (j < m) ops.push(['+', b[j++]]);
|
|
55
|
+
return ops;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Multiset fallback: how many lines of each side have no partner on the other.
|
|
59
|
+
// Ignores ordering, so it under-reports pure moves — acceptable for a size guard.
|
|
60
|
+
function approxStats(a, b) {
|
|
61
|
+
const pool = new Map();
|
|
62
|
+
for (const line of a) pool.set(line, (pool.get(line) || 0) + 1);
|
|
63
|
+
let common = 0;
|
|
64
|
+
for (const line of b) {
|
|
65
|
+
const left = pool.get(line) || 0;
|
|
66
|
+
if (left > 0) {
|
|
67
|
+
pool.set(line, left - 1);
|
|
68
|
+
common++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
common,
|
|
73
|
+
removed: a.length - common,
|
|
74
|
+
added: b.length - common,
|
|
75
|
+
approx: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function diffStats(a, b) {
|
|
80
|
+
const ops = diffOps(a, b);
|
|
81
|
+
if (!ops) return approxStats(a, b);
|
|
82
|
+
let common = 0;
|
|
83
|
+
let added = 0;
|
|
84
|
+
let removed = 0;
|
|
85
|
+
for (const [kind] of ops) {
|
|
86
|
+
if (kind === '=') common++;
|
|
87
|
+
else if (kind === '+') added++;
|
|
88
|
+
else removed++;
|
|
89
|
+
}
|
|
90
|
+
return { common, added, removed, approx: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 1.0 means identical, 0.0 means nothing in common. Two empty files are identical.
|
|
94
|
+
function similarity(stats, aLen, bLen) {
|
|
95
|
+
const span = Math.max(aLen, bLen);
|
|
96
|
+
return span === 0 ? 1 : stats.common / span;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Groups ops into unified-diff-style hunks with `context` unchanged lines around
|
|
100
|
+
// each run of changes. Returns [] when there is nothing to show.
|
|
101
|
+
function hunks(ops, context = 2) {
|
|
102
|
+
const changed = ops.map(([kind]) => kind !== '=');
|
|
103
|
+
const keep = new Array(ops.length).fill(false);
|
|
104
|
+
for (let i = 0; i < ops.length; i++) {
|
|
105
|
+
if (!changed[i]) continue;
|
|
106
|
+
for (let j = Math.max(0, i - context); j <= Math.min(ops.length - 1, i + context); j++) {
|
|
107
|
+
keep[j] = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const out = [];
|
|
112
|
+
let current = null;
|
|
113
|
+
for (let i = 0; i < ops.length; i++) {
|
|
114
|
+
if (keep[i]) {
|
|
115
|
+
if (!current) {
|
|
116
|
+
current = [];
|
|
117
|
+
out.push(current);
|
|
118
|
+
}
|
|
119
|
+
current.push(ops[i]);
|
|
120
|
+
} else {
|
|
121
|
+
current = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { splitLines, diffOps, diffStats, similarity, hunks };
|