@eddacraft/anvil-core 0.1.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/LICENSE +14 -0
- package/dist/antipattern/index.d.ts +11 -0
- package/dist/antipattern/index.d.ts.map +1 -0
- package/dist/antipattern/index.js +31 -0
- package/dist/antipattern/patterns-css.d.ts +17 -0
- package/dist/antipattern/patterns-css.d.ts.map +1 -0
- package/dist/antipattern/patterns-css.js +72 -0
- package/dist/antipattern/patterns-html.d.ts +21 -0
- package/dist/antipattern/patterns-html.d.ts.map +1 -0
- package/dist/antipattern/patterns-html.js +139 -0
- package/dist/antipattern/patterns.d.ts +72 -0
- package/dist/antipattern/patterns.d.ts.map +1 -0
- package/dist/antipattern/patterns.js +301 -0
- package/dist/antipattern/scanner.d.ts +32 -0
- package/dist/antipattern/scanner.d.ts.map +1 -0
- package/dist/antipattern/scanner.js +89 -0
- package/dist/antipattern/types.d.ts +318 -0
- package/dist/antipattern/types.d.ts.map +1 -0
- package/dist/antipattern/types.js +278 -0
- package/dist/architecture/analyzer.d.ts +123 -0
- package/dist/architecture/analyzer.d.ts.map +1 -0
- package/dist/architecture/analyzer.js +321 -0
- package/dist/architecture/baseline.d.ts +112 -0
- package/dist/architecture/baseline.d.ts.map +1 -0
- package/dist/architecture/baseline.js +245 -0
- package/dist/architecture/compiler.d.ts +24 -0
- package/dist/architecture/compiler.d.ts.map +1 -0
- package/dist/architecture/compiler.js +57 -0
- package/dist/architecture/context.d.ts +129 -0
- package/dist/architecture/context.d.ts.map +1 -0
- package/dist/architecture/context.js +116 -0
- package/dist/architecture/dc-generator.d.ts +9 -0
- package/dist/architecture/dc-generator.d.ts.map +1 -0
- package/dist/architecture/dc-generator.js +220 -0
- package/dist/architecture/definition-schema.d.ts +128 -0
- package/dist/architecture/definition-schema.d.ts.map +1 -0
- package/dist/architecture/definition-schema.js +94 -0
- package/dist/architecture/edge-detector-html.d.ts +6 -0
- package/dist/architecture/edge-detector-html.d.ts.map +1 -0
- package/dist/architecture/edge-detector-html.js +5 -0
- package/dist/architecture/edge-detector-web.d.ts +32 -0
- package/dist/architecture/edge-detector-web.d.ts.map +1 -0
- package/dist/architecture/edge-detector-web.js +133 -0
- package/dist/architecture/edge-detector.d.ts +116 -0
- package/dist/architecture/edge-detector.d.ts.map +1 -0
- package/dist/architecture/edge-detector.js +229 -0
- package/dist/architecture/entry-detector.d.ts +44 -0
- package/dist/architecture/entry-detector.d.ts.map +1 -0
- package/dist/architecture/entry-detector.js +263 -0
- package/dist/architecture/index.d.ts +21 -0
- package/dist/architecture/index.d.ts.map +1 -0
- package/dist/architecture/index.js +48 -0
- package/dist/architecture/layer-detector.d.ts +60 -0
- package/dist/architecture/layer-detector.d.ts.map +1 -0
- package/dist/architecture/layer-detector.js +331 -0
- package/dist/architecture/rego-generator.d.ts +25 -0
- package/dist/architecture/rego-generator.d.ts.map +1 -0
- package/dist/architecture/rego-generator.js +229 -0
- package/dist/architecture/templates/index.d.ts +39 -0
- package/dist/architecture/templates/index.d.ts.map +1 -0
- package/dist/architecture/templates/index.js +124 -0
- package/dist/architecture/types.d.ts +280 -0
- package/dist/architecture/types.d.ts.map +1 -0
- package/dist/architecture/types.js +269 -0
- package/dist/architecture/yaml-parser.d.ts +13 -0
- package/dist/architecture/yaml-parser.d.ts.map +1 -0
- package/dist/architecture/yaml-parser.js +234 -0
- package/dist/config/constants.d.ts +9 -0
- package/dist/config/constants.d.ts.map +1 -0
- package/dist/config/constants.js +20 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +8 -0
- package/dist/config/loader.d.ts +41 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +76 -0
- package/dist/config/nudge-config.d.ts +35 -0
- package/dist/config/nudge-config.d.ts.map +1 -0
- package/dist/config/nudge-config.js +34 -0
- package/dist/config/types.d.ts +30 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +4 -0
- package/dist/contracts/index.d.ts +14 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +13 -0
- package/dist/contracts/schemas/aps.schema.d.ts +269 -0
- package/dist/contracts/schemas/aps.schema.d.ts.map +1 -0
- package/dist/contracts/schemas/aps.schema.js +183 -0
- package/dist/contracts/schemas/index.d.ts +12 -0
- package/dist/contracts/schemas/index.d.ts.map +1 -0
- package/dist/contracts/schemas/index.js +14 -0
- package/dist/contracts/schemas/json-schema.d.ts +14 -0
- package/dist/contracts/schemas/json-schema.d.ts.map +1 -0
- package/dist/contracts/schemas/json-schema.js +31 -0
- package/dist/contracts/schemas/warning.schema.d.ts +171 -0
- package/dist/contracts/schemas/warning.schema.d.ts.map +1 -0
- package/dist/contracts/schemas/warning.schema.js +123 -0
- package/dist/contracts/types/gate.types.d.ts +194 -0
- package/dist/contracts/types/gate.types.d.ts.map +1 -0
- package/dist/contracts/types/gate.types.js +19 -0
- package/dist/contracts/types/index.d.ts +9 -0
- package/dist/contracts/types/index.d.ts.map +1 -0
- package/dist/contracts/types/index.js +8 -0
- package/dist/crypto/hash.d.ts +47 -0
- package/dist/crypto/hash.d.ts.map +1 -0
- package/dist/crypto/hash.js +110 -0
- package/dist/crypto/index.d.ts +7 -0
- package/dist/crypto/index.d.ts.map +1 -0
- package/dist/crypto/index.js +6 -0
- package/dist/drift/index.d.ts +6 -0
- package/dist/drift/index.d.ts.map +1 -0
- package/dist/drift/index.js +5 -0
- package/dist/drift/report-generator.d.ts +21 -0
- package/dist/drift/report-generator.d.ts.map +1 -0
- package/dist/drift/report-generator.js +240 -0
- package/dist/drift/snapshot-capture.d.ts +26 -0
- package/dist/drift/snapshot-capture.d.ts.map +1 -0
- package/dist/drift/snapshot-capture.js +195 -0
- package/dist/drift/snapshot-compare.d.ts +50 -0
- package/dist/drift/snapshot-compare.d.ts.map +1 -0
- package/dist/drift/snapshot-compare.js +142 -0
- package/dist/drift/snapshot-schema.d.ts +197 -0
- package/dist/drift/snapshot-schema.d.ts.map +1 -0
- package/dist/drift/snapshot-schema.js +193 -0
- package/dist/drift/snapshot-storage.d.ts +25 -0
- package/dist/drift/snapshot-storage.d.ts.map +1 -0
- package/dist/drift/snapshot-storage.js +179 -0
- package/dist/explain/antipattern-explainer.d.ts +4 -0
- package/dist/explain/antipattern-explainer.d.ts.map +1 -0
- package/dist/explain/antipattern-explainer.js +196 -0
- package/dist/explain/boundary-explainer.d.ts +5 -0
- package/dist/explain/boundary-explainer.d.ts.map +1 -0
- package/dist/explain/boundary-explainer.js +261 -0
- package/dist/explain/explain-service.d.ts +19 -0
- package/dist/explain/explain-service.d.ts.map +1 -0
- package/dist/explain/explain-service.js +106 -0
- package/dist/explain/index.d.ts +7 -0
- package/dist/explain/index.d.ts.map +1 -0
- package/dist/explain/index.js +5 -0
- package/dist/explain/template-loader.d.ts +9 -0
- package/dist/explain/template-loader.d.ts.map +1 -0
- package/dist/explain/template-loader.js +51 -0
- package/dist/explain/types.d.ts +46 -0
- package/dist/explain/types.d.ts.map +1 -0
- package/dist/explain/types.js +31 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/provenance/collector.d.ts +86 -0
- package/dist/provenance/collector.d.ts.map +1 -0
- package/dist/provenance/collector.js +425 -0
- package/dist/provenance/git-ai-standard/git-notes.d.ts +85 -0
- package/dist/provenance/git-ai-standard/git-notes.d.ts.map +1 -0
- package/dist/provenance/git-ai-standard/git-notes.js +292 -0
- package/dist/provenance/git-ai-standard/index.d.ts +44 -0
- package/dist/provenance/git-ai-standard/index.d.ts.map +1 -0
- package/dist/provenance/git-ai-standard/index.js +47 -0
- package/dist/provenance/git-ai-standard/serializer.d.ts +54 -0
- package/dist/provenance/git-ai-standard/serializer.d.ts.map +1 -0
- package/dist/provenance/git-ai-standard/serializer.js +224 -0
- package/dist/provenance/git-ai-standard/session.d.ts +51 -0
- package/dist/provenance/git-ai-standard/session.d.ts.map +1 -0
- package/dist/provenance/git-ai-standard/session.js +118 -0
- package/dist/provenance/git-ai-standard/types.d.ts +173 -0
- package/dist/provenance/git-ai-standard/types.d.ts.map +1 -0
- package/dist/provenance/git-ai-standard/types.js +109 -0
- package/dist/provenance/index.d.ts +5 -0
- package/dist/provenance/index.d.ts.map +1 -0
- package/dist/provenance/index.js +6 -0
- package/dist/provenance/store.d.ts +83 -0
- package/dist/provenance/store.d.ts.map +1 -0
- package/dist/provenance/store.js +248 -0
- package/dist/provenance/types.d.ts +160 -0
- package/dist/provenance/types.d.ts.map +1 -0
- package/dist/provenance/types.js +112 -0
- package/dist/suppression/index.d.ts +4 -0
- package/dist/suppression/index.d.ts.map +1 -0
- package/dist/suppression/index.js +3 -0
- package/dist/suppression/parser.d.ts +31 -0
- package/dist/suppression/parser.d.ts.map +1 -0
- package/dist/suppression/parser.js +219 -0
- package/dist/suppression/service.d.ts +29 -0
- package/dist/suppression/service.d.ts.map +1 -0
- package/dist/suppression/service.js +132 -0
- package/dist/suppression/store.d.ts +61 -0
- package/dist/suppression/store.d.ts.map +1 -0
- package/dist/suppression/store.js +169 -0
- package/dist/utils/debug.d.ts +48 -0
- package/dist/utils/debug.d.ts.map +1 -0
- package/dist/utils/debug.js +100 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/path-safety.d.ts +21 -0
- package/dist/utils/path-safety.d.ts.map +1 -0
- package/dist/utils/path-safety.js +49 -0
- package/dist/utils/severity.d.ts +37 -0
- package/dist/utils/severity.d.ts.map +1 -0
- package/dist/utils/severity.js +22 -0
- package/dist/validation/aps-validator.d.ts +66 -0
- package/dist/validation/aps-validator.d.ts.map +1 -0
- package/dist/validation/aps-validator.js +173 -0
- package/dist/validation/errors.d.ts +52 -0
- package/dist/validation/errors.d.ts.map +1 -0
- package/dist/validation/errors.js +115 -0
- package/dist/validation/index.d.ts +8 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +13 -0
- package/dist/warnings/index.d.ts +2 -0
- package/dist/warnings/index.d.ts.map +1 -0
- package/dist/warnings/index.js +1 -0
- package/dist/warnings/warning-id.d.ts +180 -0
- package/dist/warnings/warning-id.d.ts.map +1 -0
- package/dist/warnings/warning-id.js +257 -0
- package/package.json +79 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { serializeAuthorshipLog, parseAuthorshipLog } from './serializer.js';
|
|
5
|
+
import { createDebugger } from '../../utils/debug.js';
|
|
6
|
+
const debug = createDebugger('git-ai-notes');
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Validate a git commit SHA or ref to prevent shell injection
|
|
10
|
+
* Allows: hex characters (SHA), alphanumeric, dash, underscore, slash, tilde, caret, @, dot
|
|
11
|
+
*/
|
|
12
|
+
function isValidGitRef(ref) {
|
|
13
|
+
return /^[a-zA-Z0-9_.~^@/-]+$/.test(ref) && ref.length > 0 && ref.length <= 256;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validate a git remote name to prevent shell injection
|
|
17
|
+
* Allows: alphanumeric, dash, underscore, dot
|
|
18
|
+
*/
|
|
19
|
+
function isValidRemoteName(remote) {
|
|
20
|
+
return /^[a-zA-Z0-9_.-]+$/.test(remote) && remote.length > 0 && remote.length <= 128;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Validate a git revision range to prevent shell injection
|
|
24
|
+
* Allows: hex characters, alphanumeric, dash, underscore, dot, tilde, caret, @, colon
|
|
25
|
+
*/
|
|
26
|
+
function isValidRevisionRange(range) {
|
|
27
|
+
return /^[a-zA-Z0-9_.~^@:-]+$/.test(range) && range.length > 0 && range.length <= 512;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Git Notes namespace for AI authorship logs
|
|
31
|
+
* Per Git AI Standard v3.0.0
|
|
32
|
+
*/
|
|
33
|
+
export const NOTES_REF = 'refs/notes/ai';
|
|
34
|
+
/**
|
|
35
|
+
* Write an authorship log to Git Notes for a commit
|
|
36
|
+
*
|
|
37
|
+
* @param commitSha - The commit SHA to attach the note to
|
|
38
|
+
* @param log - The authorship log to write
|
|
39
|
+
* @param workspaceRoot - The repository root directory
|
|
40
|
+
*/
|
|
41
|
+
export async function writeAuthorshipNote(commitSha, log, workspaceRoot) {
|
|
42
|
+
if (!isValidGitRef(commitSha)) {
|
|
43
|
+
throw new Error(`Invalid commit SHA: ${commitSha}`);
|
|
44
|
+
}
|
|
45
|
+
const content = serializeAuthorshipLog(log);
|
|
46
|
+
// Write content to a temp file in OS temp directory to avoid issues with
|
|
47
|
+
// worktrees/submodules where .git may be a file, not a directory
|
|
48
|
+
const { writeFile, unlink } = await import('node:fs/promises');
|
|
49
|
+
const { join } = await import('node:path');
|
|
50
|
+
const { randomUUID } = await import('node:crypto');
|
|
51
|
+
const tempFile = join(tmpdir(), `anvil-note-${randomUUID()}.tmp`);
|
|
52
|
+
try {
|
|
53
|
+
await writeFile(tempFile, content, 'utf-8');
|
|
54
|
+
await execFileAsync('git', ['notes', `--ref=${NOTES_REF}`, 'add', '-f', '-F', tempFile, '--', commitSha], {
|
|
55
|
+
cwd: workspaceRoot,
|
|
56
|
+
});
|
|
57
|
+
debug(`Wrote authorship note for commit ${commitSha.slice(0, 8)}`);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
debug('Failed to write authorship note', error);
|
|
61
|
+
throw new Error(`Failed to write authorship note: ${error}`);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
// Clean up temp file
|
|
65
|
+
try {
|
|
66
|
+
await unlink(tempFile);
|
|
67
|
+
}
|
|
68
|
+
catch (cleanupError) {
|
|
69
|
+
debug('Failed to clean up temp file', { tempFile, error: cleanupError });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Read an authorship log from Git Notes for a commit
|
|
75
|
+
*
|
|
76
|
+
* @param commitSha - The commit SHA to read the note from (or 'HEAD')
|
|
77
|
+
* @param workspaceRoot - The repository root directory
|
|
78
|
+
* @returns The parsed AuthorshipLog, or null if no note exists
|
|
79
|
+
*/
|
|
80
|
+
export async function readAuthorshipNote(commitSha, workspaceRoot) {
|
|
81
|
+
if (!isValidGitRef(commitSha)) {
|
|
82
|
+
throw new Error(`Invalid commit SHA: ${commitSha}`);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await execFileAsync('git', ['notes', `--ref=${NOTES_REF}`, 'show', '--', commitSha], {
|
|
86
|
+
cwd: workspaceRoot,
|
|
87
|
+
});
|
|
88
|
+
return parseAuthorshipLog(stdout);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// Note doesn't exist or other error
|
|
92
|
+
debug(`No authorship note found for commit ${commitSha.slice(0, 8)}`, error);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* List all commits with authorship notes
|
|
98
|
+
*
|
|
99
|
+
* @param workspaceRoot - The repository root directory
|
|
100
|
+
* @returns Array of commit SHAs that have authorship notes
|
|
101
|
+
*/
|
|
102
|
+
export async function listAuthorshipNotes(workspaceRoot) {
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await execFileAsync('git', ['notes', `--ref=${NOTES_REF}`, 'list'], {
|
|
105
|
+
cwd: workspaceRoot,
|
|
106
|
+
});
|
|
107
|
+
// Format: <note-sha> <commit-sha>
|
|
108
|
+
return stdout
|
|
109
|
+
.trim()
|
|
110
|
+
.split('\n')
|
|
111
|
+
.filter((line) => line)
|
|
112
|
+
.map((line) => line.split(' ')[1])
|
|
113
|
+
.filter((sha) => !!sha);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
debug('Failed to list authorship notes', error);
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Remove an authorship note from a commit
|
|
122
|
+
*
|
|
123
|
+
* @param commitSha - The commit SHA to remove the note from
|
|
124
|
+
* @param workspaceRoot - The repository root directory
|
|
125
|
+
* @returns true if the note was removed, false if it didn't exist
|
|
126
|
+
*/
|
|
127
|
+
export async function removeAuthorshipNote(commitSha, workspaceRoot) {
|
|
128
|
+
if (!isValidGitRef(commitSha)) {
|
|
129
|
+
throw new Error(`Invalid commit SHA: ${commitSha}`);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await execFileAsync('git', ['notes', `--ref=${NOTES_REF}`, 'remove', '--', commitSha], {
|
|
133
|
+
cwd: workspaceRoot,
|
|
134
|
+
});
|
|
135
|
+
debug(`Removed authorship note for commit ${commitSha.slice(0, 8)}`);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
debug('Failed to remove authorship note (may not exist)', error);
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Copy authorship note when rebasing (from old SHA to new SHA)
|
|
145
|
+
*
|
|
146
|
+
* Updates the base_commit_sha in metadata to point to the new commit.
|
|
147
|
+
*
|
|
148
|
+
* @param fromSha - The original commit SHA
|
|
149
|
+
* @param toSha - The new commit SHA after rebase
|
|
150
|
+
* @param workspaceRoot - The repository root directory
|
|
151
|
+
* @returns true if the note was copied, false if source note didn't exist
|
|
152
|
+
*/
|
|
153
|
+
export async function copyAuthorshipNote(fromSha, toSha, workspaceRoot) {
|
|
154
|
+
if (!isValidGitRef(fromSha) || !isValidGitRef(toSha)) {
|
|
155
|
+
throw new Error(`Invalid commit SHA: fromSha=${fromSha}, toSha=${toSha}`);
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const existingLog = await readAuthorshipNote(fromSha, workspaceRoot);
|
|
159
|
+
if (!existingLog)
|
|
160
|
+
return false;
|
|
161
|
+
// Resolve toSha to full 40-character SHA
|
|
162
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--', toSha], {
|
|
163
|
+
cwd: workspaceRoot,
|
|
164
|
+
});
|
|
165
|
+
const resolvedSha = stdout.trim();
|
|
166
|
+
// Update base_commit_sha in metadata to point to new commit
|
|
167
|
+
const updatedLog = {
|
|
168
|
+
...existingLog,
|
|
169
|
+
metadata: {
|
|
170
|
+
...existingLog.metadata,
|
|
171
|
+
base_commit_sha: resolvedSha,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
await writeAuthorshipNote(toSha, updatedLog, workspaceRoot);
|
|
175
|
+
debug(`Copied authorship note from ${fromSha.slice(0, 8)} to ${toSha.slice(0, 8)}`);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
debug(`Failed to copy authorship note from ${fromSha} to ${toSha}`, error);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Push authorship notes to remote
|
|
185
|
+
*
|
|
186
|
+
* @param remote - Remote name (e.g., 'origin')
|
|
187
|
+
* @param workspaceRoot - The repository root directory
|
|
188
|
+
*/
|
|
189
|
+
export async function pushAuthorshipNotes(remote, workspaceRoot) {
|
|
190
|
+
if (!isValidRemoteName(remote)) {
|
|
191
|
+
throw new Error(`Invalid remote name: ${remote}`);
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
await execFileAsync('git', ['push', remote, NOTES_REF], {
|
|
195
|
+
cwd: workspaceRoot,
|
|
196
|
+
});
|
|
197
|
+
debug(`Pushed authorship notes to ${remote}`);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
debug('Failed to push authorship notes', error);
|
|
201
|
+
throw new Error(`Failed to push authorship notes: ${error}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Fetch authorship notes from remote
|
|
206
|
+
*
|
|
207
|
+
* @param remote - Remote name (e.g., 'origin')
|
|
208
|
+
* @param workspaceRoot - The repository root directory
|
|
209
|
+
*/
|
|
210
|
+
export async function fetchAuthorshipNotes(remote, workspaceRoot) {
|
|
211
|
+
if (!isValidRemoteName(remote)) {
|
|
212
|
+
throw new Error(`Invalid remote name: ${remote}`);
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
await execFileAsync('git', ['fetch', remote, `${NOTES_REF}:${NOTES_REF}`], {
|
|
216
|
+
cwd: workspaceRoot,
|
|
217
|
+
});
|
|
218
|
+
debug(`Fetched authorship notes from ${remote}`);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
debug('Failed to fetch authorship notes', error);
|
|
222
|
+
throw new Error(`Failed to fetch authorship notes: ${error}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Check if a commit has an authorship note
|
|
227
|
+
*
|
|
228
|
+
* @param commitSha - The commit SHA to check
|
|
229
|
+
* @param workspaceRoot - The repository root directory
|
|
230
|
+
* @returns true if the commit has an authorship note
|
|
231
|
+
*/
|
|
232
|
+
export async function hasAuthorshipNote(commitSha, workspaceRoot) {
|
|
233
|
+
if (!isValidGitRef(commitSha)) {
|
|
234
|
+
throw new Error(`Invalid commit SHA: ${commitSha}`);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
await execFileAsync('git', ['notes', `--ref=${NOTES_REF}`, 'show', '--', commitSha], {
|
|
238
|
+
cwd: workspaceRoot,
|
|
239
|
+
});
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Get summary statistics of AI authorship in a range of commits
|
|
248
|
+
*
|
|
249
|
+
* @param range - Git revision range (e.g., 'main..HEAD', 'HEAD~10..HEAD')
|
|
250
|
+
* @param workspaceRoot - The repository root directory
|
|
251
|
+
* @returns Summary object with counts
|
|
252
|
+
*/
|
|
253
|
+
export async function getAuthorshipStats(range, workspaceRoot) {
|
|
254
|
+
if (!isValidRevisionRange(range)) {
|
|
255
|
+
throw new Error(`Invalid revision range: ${range}`);
|
|
256
|
+
}
|
|
257
|
+
const stats = {
|
|
258
|
+
totalCommits: 0,
|
|
259
|
+
commitsWithAI: 0,
|
|
260
|
+
totalAdditions: 0,
|
|
261
|
+
totalDeletions: 0,
|
|
262
|
+
tools: {},
|
|
263
|
+
};
|
|
264
|
+
try {
|
|
265
|
+
// Get list of commits in range
|
|
266
|
+
const { stdout } = await execFileAsync('git', ['rev-list', '--', range], {
|
|
267
|
+
cwd: workspaceRoot,
|
|
268
|
+
});
|
|
269
|
+
const commits = stdout.trim().split('\n').filter(Boolean);
|
|
270
|
+
stats.totalCommits = commits.length;
|
|
271
|
+
// Batch: get all commits that have authorship notes in a single git command
|
|
272
|
+
const notedCommits = new Set(await listAuthorshipNotes(workspaceRoot));
|
|
273
|
+
// Only read notes for commits that are both in the range AND have notes
|
|
274
|
+
const commitsWithNotes = commits.filter((commit) => notedCommits.has(commit));
|
|
275
|
+
for (const commit of commitsWithNotes) {
|
|
276
|
+
const log = await readAuthorshipNote(commit, workspaceRoot);
|
|
277
|
+
if (log) {
|
|
278
|
+
stats.commitsWithAI++;
|
|
279
|
+
for (const prompt of Object.values(log.metadata.prompts)) {
|
|
280
|
+
stats.totalAdditions += prompt.total_additions;
|
|
281
|
+
stats.totalDeletions += prompt.total_deletions;
|
|
282
|
+
const tool = prompt.agent_id.tool;
|
|
283
|
+
stats.tools[tool] = (stats.tools[tool] || 0) + 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
debug('Failed to get authorship stats', error);
|
|
290
|
+
}
|
|
291
|
+
return stats;
|
|
292
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git AI Standard v3.0.0 Implementation
|
|
3
|
+
*
|
|
4
|
+
* This module provides support for tracking AI-generated code contributions
|
|
5
|
+
* using Git Notes under the refs/notes/ai namespace.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/git-ai-project/git-ai/blob/main/specs/git_ai_standard_v3.0.0.md
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import {
|
|
12
|
+
* writeAuthorshipNote,
|
|
13
|
+
* readAuthorshipNote,
|
|
14
|
+
* generateSessionHash,
|
|
15
|
+
* SCHEMA_VERSION,
|
|
16
|
+
* type AuthorshipLog,
|
|
17
|
+
* } from '@eddacraft/anvil-core';
|
|
18
|
+
*
|
|
19
|
+
* // Create an authorship log
|
|
20
|
+
* const log: AuthorshipLog = {
|
|
21
|
+
* attestations: {
|
|
22
|
+
* 'src/feature.ts': [
|
|
23
|
+
* { sessionHash: generateSessionHash('claude-code', 'session-123'), lineRanges: '1-50' }
|
|
24
|
+
* ]
|
|
25
|
+
* },
|
|
26
|
+
* metadata: {
|
|
27
|
+
* schema_version: SCHEMA_VERSION,
|
|
28
|
+
* base_commit_sha: 'abc123...',
|
|
29
|
+
* prompts: { ... }
|
|
30
|
+
* }
|
|
31
|
+
* };
|
|
32
|
+
*
|
|
33
|
+
* // Attach to commit
|
|
34
|
+
* await writeAuthorshipNote(commitSha, log, workspaceRoot);
|
|
35
|
+
*
|
|
36
|
+
* // Read back
|
|
37
|
+
* const retrieved = await readAuthorshipNote(commitSha, workspaceRoot);
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export { SCHEMA_VERSION, SessionHashSchema, LineRangeSchema, FileAttestationSchema, AgentIdSchema, MessageTypeSchema, MessageSchema, PromptRecordSchema, AuthorshipMetadataSchema, AuthorshipLogSchema, type SessionHash, type LineRange, type FileAttestation, type AgentId, type MessageType, type Message, type PromptRecord, type AuthorshipMetadata, type AuthorshipLog, } from './types.js';
|
|
41
|
+
export { serializeAuthorshipLog, parseAuthorshipLog, isAuthorshipLog, expandLineRanges, compactLineRanges, } from './serializer.js';
|
|
42
|
+
export { generateSessionHash, sessionHashFromAgentId, createAgentId, detectCurrentAgent, createExplicitAgent, formatAgentId, } from './session.js';
|
|
43
|
+
export { NOTES_REF, writeAuthorshipNote, readAuthorshipNote, listAuthorshipNotes, removeAuthorshipNote, copyAuthorshipNote, pushAuthorshipNotes, fetchAuthorshipNotes, hasAuthorshipNote, getAuthorshipStats, } from './git-notes.js';
|
|
44
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/provenance/git-ai-standard/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAGH,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,eAAe,EACf,qBAAqB,EACrB,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,kBAAkB,EAClB,wBAAwB,EACxB,mBAAmB,EACnB,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,OAAO,EACZ,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,YAAY,EACjB,KAAK,kBAAkB,EACvB,KAAK,aAAa,GACnB,MAAM,YAAY,CAAC;AAGpB,OAAO,EACL,sBAAsB,EACtB,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EACL,mBAAmB,EACnB,sBAAsB,EACtB,aAAa,EACb,kBAAkB,EAClB,mBAAmB,EACnB,aAAa,GACd,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,SAAS,EACT,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,EACnB,oBAAoB,EACpB,kBAAkB,EAClB,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git AI Standard v3.0.0 Implementation
|
|
3
|
+
*
|
|
4
|
+
* This module provides support for tracking AI-generated code contributions
|
|
5
|
+
* using Git Notes under the refs/notes/ai namespace.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/git-ai-project/git-ai/blob/main/specs/git_ai_standard_v3.0.0.md
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import {
|
|
12
|
+
* writeAuthorshipNote,
|
|
13
|
+
* readAuthorshipNote,
|
|
14
|
+
* generateSessionHash,
|
|
15
|
+
* SCHEMA_VERSION,
|
|
16
|
+
* type AuthorshipLog,
|
|
17
|
+
* } from '@eddacraft/anvil-core';
|
|
18
|
+
*
|
|
19
|
+
* // Create an authorship log
|
|
20
|
+
* const log: AuthorshipLog = {
|
|
21
|
+
* attestations: {
|
|
22
|
+
* 'src/feature.ts': [
|
|
23
|
+
* { sessionHash: generateSessionHash('claude-code', 'session-123'), lineRanges: '1-50' }
|
|
24
|
+
* ]
|
|
25
|
+
* },
|
|
26
|
+
* metadata: {
|
|
27
|
+
* schema_version: SCHEMA_VERSION,
|
|
28
|
+
* base_commit_sha: 'abc123...',
|
|
29
|
+
* prompts: { ... }
|
|
30
|
+
* }
|
|
31
|
+
* };
|
|
32
|
+
*
|
|
33
|
+
* // Attach to commit
|
|
34
|
+
* await writeAuthorshipNote(commitSha, log, workspaceRoot);
|
|
35
|
+
*
|
|
36
|
+
* // Read back
|
|
37
|
+
* const retrieved = await readAuthorshipNote(commitSha, workspaceRoot);
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
// Types
|
|
41
|
+
export { SCHEMA_VERSION, SessionHashSchema, LineRangeSchema, FileAttestationSchema, AgentIdSchema, MessageTypeSchema, MessageSchema, PromptRecordSchema, AuthorshipMetadataSchema, AuthorshipLogSchema, } from './types.js';
|
|
42
|
+
// Serialization
|
|
43
|
+
export { serializeAuthorshipLog, parseAuthorshipLog, isAuthorshipLog, expandLineRanges, compactLineRanges, } from './serializer.js';
|
|
44
|
+
// Session management
|
|
45
|
+
export { generateSessionHash, sessionHashFromAgentId, createAgentId, detectCurrentAgent, createExplicitAgent, formatAgentId, } from './session.js';
|
|
46
|
+
// Git Notes operations
|
|
47
|
+
export { NOTES_REF, writeAuthorshipNote, readAuthorshipNote, listAuthorshipNotes, removeAuthorshipNote, copyAuthorshipNote, pushAuthorshipNotes, fetchAuthorshipNotes, hasAuthorshipNote, getAuthorshipStats, } from './git-notes.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AuthorshipLog } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Serialize an AuthorshipLog to Git AI Standard format
|
|
4
|
+
*
|
|
5
|
+
* The format consists of two sections separated by "---":
|
|
6
|
+
*
|
|
7
|
+
* ```
|
|
8
|
+
* file/path.ts
|
|
9
|
+
* a1b2c3d4e5f67890 1-50,55-60
|
|
10
|
+
* another/file.ts
|
|
11
|
+
* b2c3d4e5f67890a1 1-100
|
|
12
|
+
* ---
|
|
13
|
+
* {"schema_version":"authorship/3.0.0",...}
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* File paths with spaces or special characters are quoted.
|
|
17
|
+
*
|
|
18
|
+
* @param log - The authorship log to serialize
|
|
19
|
+
* @returns The serialized log string
|
|
20
|
+
*/
|
|
21
|
+
export declare function serializeAuthorshipLog(log: AuthorshipLog): string;
|
|
22
|
+
/**
|
|
23
|
+
* Parse a Git AI Standard authorship log
|
|
24
|
+
*
|
|
25
|
+
* @param content - The raw log content from Git Notes
|
|
26
|
+
* @returns The parsed AuthorshipLog
|
|
27
|
+
* @throws Error if the content is malformed
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseAuthorshipLog(content: string): AuthorshipLog;
|
|
30
|
+
/**
|
|
31
|
+
* Check if content looks like an authorship log
|
|
32
|
+
*
|
|
33
|
+
* @param content - Content to check
|
|
34
|
+
* @returns true if the content appears to be a valid authorship log
|
|
35
|
+
*/
|
|
36
|
+
export declare function isAuthorshipLog(content: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Parse line ranges into an array of line numbers
|
|
39
|
+
*
|
|
40
|
+
* Validates each part and skips invalid entries with a warning rather than
|
|
41
|
+
* throwing. Empty parts, NaN values, and ranges where start > end are skipped.
|
|
42
|
+
*
|
|
43
|
+
* @param ranges - Line range string (e.g., "1-10,15,20-25")
|
|
44
|
+
* @returns Array of individual line numbers
|
|
45
|
+
*/
|
|
46
|
+
export declare function expandLineRanges(ranges: string): number[];
|
|
47
|
+
/**
|
|
48
|
+
* Compact an array of line numbers into ranges
|
|
49
|
+
*
|
|
50
|
+
* @param lines - Array of line numbers
|
|
51
|
+
* @returns Compact range string (e.g., "1-10,15,20-25")
|
|
52
|
+
*/
|
|
53
|
+
export declare function compactLineRanges(lines: number[]): string;
|
|
54
|
+
//# sourceMappingURL=serializer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../../../src/provenance/git-ai-standard/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAmB,MAAM,YAAY,CAAC;AAMjE;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAyBjE;AASD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CAuCjE;AAwDD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAMxD;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CA+CzD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAsBzD"}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { AuthorshipLogSchema, SCHEMA_VERSION } from './types.js';
|
|
2
|
+
import { createDebugger } from '../../utils/debug.js';
|
|
3
|
+
const debug = createDebugger('provenance');
|
|
4
|
+
/**
|
|
5
|
+
* Serialize an AuthorshipLog to Git AI Standard format
|
|
6
|
+
*
|
|
7
|
+
* The format consists of two sections separated by "---":
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* file/path.ts
|
|
11
|
+
* a1b2c3d4e5f67890 1-50,55-60
|
|
12
|
+
* another/file.ts
|
|
13
|
+
* b2c3d4e5f67890a1 1-100
|
|
14
|
+
* ---
|
|
15
|
+
* {"schema_version":"authorship/3.0.0",...}
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* File paths with spaces or special characters are quoted.
|
|
19
|
+
*
|
|
20
|
+
* @param log - The authorship log to serialize
|
|
21
|
+
* @returns The serialized log string
|
|
22
|
+
*/
|
|
23
|
+
export function serializeAuthorshipLog(log) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
// Attestation section
|
|
26
|
+
const sortedPaths = Object.keys(log.attestations).sort();
|
|
27
|
+
for (const filePath of sortedPaths) {
|
|
28
|
+
const attestations = log.attestations[filePath];
|
|
29
|
+
if (!attestations || attestations.length === 0)
|
|
30
|
+
continue;
|
|
31
|
+
// Quote paths with spaces or special characters
|
|
32
|
+
const quotedPath = needsQuoting(filePath) ? `"${filePath}"` : filePath;
|
|
33
|
+
lines.push(quotedPath);
|
|
34
|
+
for (const attestation of attestations) {
|
|
35
|
+
lines.push(` ${attestation.sessionHash} ${attestation.lineRanges}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Separator
|
|
39
|
+
lines.push('---');
|
|
40
|
+
// Metadata section (JSON, pretty-printed for readability)
|
|
41
|
+
lines.push(JSON.stringify(log.metadata, null, 2));
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Check if a file path needs to be quoted
|
|
46
|
+
*/
|
|
47
|
+
function needsQuoting(path) {
|
|
48
|
+
return /[\s"'\\]/.test(path);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse a Git AI Standard authorship log
|
|
52
|
+
*
|
|
53
|
+
* @param content - The raw log content from Git Notes
|
|
54
|
+
* @returns The parsed AuthorshipLog
|
|
55
|
+
* @throws Error if the content is malformed
|
|
56
|
+
*/
|
|
57
|
+
export function parseAuthorshipLog(content) {
|
|
58
|
+
const separatorIndex = content.indexOf('\n---\n');
|
|
59
|
+
if (separatorIndex === -1) {
|
|
60
|
+
// Try alternate separator (just --- at start of line)
|
|
61
|
+
const altIndex = content.indexOf('\n---');
|
|
62
|
+
if (altIndex === -1) {
|
|
63
|
+
throw new Error('Invalid authorship log: missing --- separator');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const actualSeparatorIndex = content.indexOf('\n---\n') !== -1 ? content.indexOf('\n---\n') : content.indexOf('\n---');
|
|
67
|
+
const attestationSection = content.slice(0, actualSeparatorIndex);
|
|
68
|
+
const metadataSection = content.slice(actualSeparatorIndex).replace(/^\n---\n?/, '');
|
|
69
|
+
// Parse attestations
|
|
70
|
+
const attestations = parseAttestationSection(attestationSection);
|
|
71
|
+
// Parse metadata JSON
|
|
72
|
+
const metadataJson = metadataSection.trim();
|
|
73
|
+
if (!metadataJson) {
|
|
74
|
+
throw new Error('Invalid authorship log: empty metadata section');
|
|
75
|
+
}
|
|
76
|
+
let metadata;
|
|
77
|
+
try {
|
|
78
|
+
metadata = JSON.parse(metadataJson);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
throw new Error(`Invalid authorship log: malformed JSON in metadata section - ${e}`);
|
|
82
|
+
}
|
|
83
|
+
// Validate with Zod schema
|
|
84
|
+
const result = AuthorshipLogSchema.safeParse({ attestations, metadata });
|
|
85
|
+
if (!result.success) {
|
|
86
|
+
throw new Error(`Invalid authorship log: ${result.error.message}`);
|
|
87
|
+
}
|
|
88
|
+
return result.data;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse the attestation section of an authorship log
|
|
92
|
+
*/
|
|
93
|
+
function parseAttestationSection(section) {
|
|
94
|
+
const attestations = {};
|
|
95
|
+
let currentFile = null;
|
|
96
|
+
for (const line of section.split('\n')) {
|
|
97
|
+
if (!line.trim())
|
|
98
|
+
continue;
|
|
99
|
+
// Check if this is a file path line (not indented)
|
|
100
|
+
if (!line.startsWith(' ') && !line.startsWith('\t')) {
|
|
101
|
+
currentFile = parseFilePath(line);
|
|
102
|
+
attestations[currentFile] = [];
|
|
103
|
+
}
|
|
104
|
+
else if (currentFile) {
|
|
105
|
+
// This is an attestation entry line (indented)
|
|
106
|
+
const attestation = parseAttestationLine(line.trim());
|
|
107
|
+
if (attestation) {
|
|
108
|
+
attestations[currentFile].push(attestation);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return attestations;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Parse a file path, handling quoted paths
|
|
116
|
+
*/
|
|
117
|
+
function parseFilePath(line) {
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
120
|
+
return trimmed.slice(1, -1);
|
|
121
|
+
}
|
|
122
|
+
return trimmed;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Parse an attestation entry line
|
|
126
|
+
*
|
|
127
|
+
* Format: <session-hash> <line-ranges>
|
|
128
|
+
* Example: a1b2c3d4e5f67890 1-50,55-60
|
|
129
|
+
*/
|
|
130
|
+
function parseAttestationLine(line) {
|
|
131
|
+
// Match 7-16 hex characters followed by space and line ranges
|
|
132
|
+
const match = line.match(/^([a-f0-9]{7,16})\s+(.+)$/);
|
|
133
|
+
if (!match)
|
|
134
|
+
return null;
|
|
135
|
+
return {
|
|
136
|
+
sessionHash: match[1],
|
|
137
|
+
lineRanges: match[2],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Check if content looks like an authorship log
|
|
142
|
+
*
|
|
143
|
+
* @param content - Content to check
|
|
144
|
+
* @returns true if the content appears to be a valid authorship log
|
|
145
|
+
*/
|
|
146
|
+
export function isAuthorshipLog(content) {
|
|
147
|
+
return (content.includes('\n---\n') &&
|
|
148
|
+
content.includes(`"schema_version"`) &&
|
|
149
|
+
content.includes(SCHEMA_VERSION));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Parse line ranges into an array of line numbers
|
|
153
|
+
*
|
|
154
|
+
* Validates each part and skips invalid entries with a warning rather than
|
|
155
|
+
* throwing. Empty parts, NaN values, and ranges where start > end are skipped.
|
|
156
|
+
*
|
|
157
|
+
* @param ranges - Line range string (e.g., "1-10,15,20-25")
|
|
158
|
+
* @returns Array of individual line numbers
|
|
159
|
+
*/
|
|
160
|
+
export function expandLineRanges(ranges) {
|
|
161
|
+
const lines = [];
|
|
162
|
+
for (const part of ranges.split(',')) {
|
|
163
|
+
const trimmed = part.trim();
|
|
164
|
+
// Skip empty parts (e.g., trailing commas, double commas)
|
|
165
|
+
if (trimmed === '') {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (trimmed.includes('-')) {
|
|
169
|
+
const segments = trimmed.split('-');
|
|
170
|
+
if (segments.length !== 2) {
|
|
171
|
+
debug(`[anvil] expandLineRanges: skipping invalid range part "${trimmed}"`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const start = Number(segments[0]);
|
|
175
|
+
const end = Number(segments[1]);
|
|
176
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
|
177
|
+
debug(`[anvil] expandLineRanges: skipping non-integer range "${trimmed}"`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (start > end) {
|
|
181
|
+
debug(`[anvil] expandLineRanges: skipping invalid range "${trimmed}" (start > end)`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
for (let i = start; i <= end; i++) {
|
|
185
|
+
lines.push(i);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const num = Number(trimmed);
|
|
190
|
+
if (!Number.isInteger(num)) {
|
|
191
|
+
debug(`[anvil] expandLineRanges: skipping non-integer value "${trimmed}"`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
lines.push(num);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return lines.sort((a, b) => a - b);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Compact an array of line numbers into ranges
|
|
201
|
+
*
|
|
202
|
+
* @param lines - Array of line numbers
|
|
203
|
+
* @returns Compact range string (e.g., "1-10,15,20-25")
|
|
204
|
+
*/
|
|
205
|
+
export function compactLineRanges(lines) {
|
|
206
|
+
if (lines.length === 0)
|
|
207
|
+
return '';
|
|
208
|
+
const sorted = [...new Set(lines)].sort((a, b) => a - b);
|
|
209
|
+
const ranges = [];
|
|
210
|
+
let rangeStart = sorted[0];
|
|
211
|
+
let rangeEnd = sorted[0];
|
|
212
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
213
|
+
if (sorted[i] === rangeEnd + 1) {
|
|
214
|
+
rangeEnd = sorted[i];
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
ranges.push(rangeStart === rangeEnd ? `${rangeStart}` : `${rangeStart}-${rangeEnd}`);
|
|
218
|
+
rangeStart = sorted[i];
|
|
219
|
+
rangeEnd = sorted[i];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
ranges.push(rangeStart === rangeEnd ? `${rangeStart}` : `${rangeStart}-${rangeEnd}`);
|
|
223
|
+
return ranges.join(',');
|
|
224
|
+
}
|