@feng0531/dsh-code-review 0.83.0 → 0.84.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/CHANGELOG.md +8 -0
- package/CORE-ALIGNMENT-TASKS.md +1 -1
- package/DEVELOPMENT.md +1 -1
- package/README.md +16 -2
- package/RELEASE-NOTES.md +8 -6
- package/index.mjs +11 -12
- package/package.json +1 -1
- package/src/background-review.mjs +84 -12
- package/src/business-groups.mjs +1 -1
- package/src/class-bindings.mjs +45 -12
- package/src/defect-guidance.mjs +158 -16
- package/src/dsh-model.mjs +107 -15
- package/src/git-change-scope.mjs +39 -0
- package/src/git-history.mjs +202 -56
- package/src/git.mjs +192 -50
- package/src/import-bindings.mjs +74 -65
- package/src/job-progress.mjs +21 -2
- package/src/model-contract.mjs +17 -1
- package/src/primary-input.mjs +87 -26
- package/src/project-report.mjs +22 -5
- package/src/project-review-service.mjs +177 -37
- package/src/project-snapshot.mjs +101 -40
- package/src/prompts.mjs +9 -0
- package/src/protocol-guidance.mjs +13 -1
- package/src/recovery-coverage.mjs +25 -1
- package/src/retrieval-loop.mjs +66 -28
- package/src/review.mjs +39 -33
- package/src/risk-plan.mjs +1 -1
- package/src/service.mjs +140 -6
- package/src/snapshot-reader.mjs +18 -5
- package/src/snapshot.mjs +75 -42
- package/src/svn-capture-budget.mjs +22 -5
- package/src/svn-effective-mergeinfo.mjs +59 -37
- package/src/svn-eol-mapping.mjs +22 -10
- package/src/svn-history-endpoints.mjs +18 -12
- package/src/svn-history-lookup.mjs +34 -26
- package/src/svn-keyword-config.mjs +11 -8
- package/src/svn-keyword-mapping.mjs +67 -36
- package/src/svn-mergeinfo.mjs +34 -22
- package/src/svn-navigation-budget.mjs +15 -4
- package/src/svn-special.mjs +13 -8
- package/src/svn-url.mjs +18 -6
- package/src/svn-working-identity.mjs +22 -8
- package/src/svn-working-scope.mjs +16 -5
- package/src/tool-view.mjs +5 -0
- package/src/verification-loop.mjs +2 -1
package/src/snapshot.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {createSvnCaptureBudget} from './svn-capture-budget.mjs';
|
|
2
|
-
import {captureSvnDependencyChain} from './svn-dependency-chain.mjs';
|
|
3
|
-
import {createSvnNavigationBudget} from './svn-navigation-budget.mjs';
|
|
4
|
-
import {captureSvnExplicitContext} from './svn-explicit-context.mjs';
|
|
5
|
-
import {captureDependencyChain} from './dependency-chain.mjs';
|
|
6
|
-
import {captureSvnCallers} from './svn-caller-capture.mjs';
|
|
1
|
+
import { createSvnCaptureBudget } from './svn-capture-budget.mjs';
|
|
2
|
+
import { captureSvnDependencyChain } from './svn-dependency-chain.mjs';
|
|
3
|
+
import { createSvnNavigationBudget } from './svn-navigation-budget.mjs';
|
|
4
|
+
import { captureSvnExplicitContext } from './svn-explicit-context.mjs';
|
|
5
|
+
import { captureDependencyChain } from './dependency-chain.mjs';
|
|
6
|
+
import { captureSvnCallers } from './svn-caller-capture.mjs';
|
|
7
7
|
import { captureCallers } from './caller-capture.mjs';
|
|
8
8
|
import { validateCallerScopes } from './caller-index.mjs';
|
|
9
9
|
import { bindBusinessRequirement, validateBusinessRequirement } from './business-requirement.mjs';
|
|
@@ -16,6 +16,7 @@ import { captureProjectRules } from './project-rules.mjs';
|
|
|
16
16
|
import { captureSvn } from './svn.mjs';
|
|
17
17
|
import { captureSvnHistory } from './svn-history.mjs';
|
|
18
18
|
import { readLocal, relativePath, hash } from './content.mjs';
|
|
19
|
+
import { CHANGE_SCOPES } from './git-change-scope.mjs';
|
|
19
20
|
|
|
20
21
|
export async function detectVcs(cwd) {
|
|
21
22
|
let current = await realpath(cwd);
|
|
@@ -32,7 +33,8 @@ export async function detectVcs(cwd) {
|
|
|
32
33
|
current = parent;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
+
|
|
37
|
+
function validateSnapshotOptions(options) {
|
|
36
38
|
validateBusinessRequirement(options.businessRequirement);
|
|
37
39
|
const { rulePaths = [], contextPaths = [], maxContextFiles = 20, maxSnapshotBytes = 4 * 1024 * 1024 } = options;
|
|
38
40
|
for (const value of [maxContextFiles, maxSnapshotBytes]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid snapshot limit');
|
|
@@ -42,77 +44,107 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
42
44
|
if (options.callerScopePaths !== undefined) validateCallerScopes(options.callerScopePaths);
|
|
43
45
|
if (options.oldContextPaths !== undefined && (!Array.isArray(options.oldContextPaths) || options.oldContextPaths.length > 20 || new Set(options.oldContextPaths).size !== options.oldContextPaths.length)) throw new Error('Invalid old context paths');
|
|
44
46
|
for (const name of options.oldContextPaths ?? []) relativePath(name);
|
|
47
|
+
return { rulePaths, contextPaths, maxContextFiles, maxSnapshotBytes };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function captureSnapshot(cwd, options = {}) {
|
|
51
|
+
const { rulePaths, contextPaths, maxContextFiles, maxSnapshotBytes } = validateSnapshotOptions(options);
|
|
52
|
+
|
|
45
53
|
const type = await detectVcs(cwd);
|
|
46
|
-
if(options.
|
|
47
|
-
if(
|
|
48
|
-
if(
|
|
49
|
-
if(
|
|
54
|
+
if (options.changeScope !== undefined) {
|
|
55
|
+
if (typeof options.changeScope !== 'string' || !CHANGE_SCOPES.includes(options.changeScope)) throw new Error('Invalid changeScope; expected worktree, staged or unstaged');
|
|
56
|
+
if (['commit', 'baseRevision', 'targetRevision', 'svnRevision', 'svnBaseRevision', 'svnTargetRevision', 'svnBasePath', 'svnTargetPath'].some(key => options[key] !== undefined)) throw new Error('changeScope applies to Git working-copy comparison only');
|
|
57
|
+
if (type !== 'git') throw new Error('changeScope requires a Git working copy');
|
|
58
|
+
}
|
|
59
|
+
const replacementOptions = options.svnReplacementBaseRevision !== undefined || options.svnReplacementAllowNetwork !== undefined;
|
|
60
|
+
if (replacementOptions) {
|
|
61
|
+
if (type !== 'svn' || ['svnRevision', 'svnBaseRevision', 'svnTargetRevision', 'svnBasePath', 'svnTargetPath', 'commit', 'baseRevision', 'targetRevision'].some(key => options[key] !== undefined)) throw new Error('Replacement repository baseline requires SVN working-copy mode');
|
|
62
|
+
if (typeof options.svnReplacementBaseRevision !== 'string' || !/^(0|[1-9][0-9]*)$/.test(options.svnReplacementBaseRevision) || !Number.isSafeInteger(Number(options.svnReplacementBaseRevision))) throw new Error('Replacement baseline requires fixed numeric revision');
|
|
63
|
+
if (options.svnReplacementAllowNetwork !== undefined && typeof options.svnReplacementAllowNetwork !== 'boolean') throw new Error('Invalid replacement network permission');
|
|
50
64
|
}
|
|
51
|
-
if(options.svnEffectiveMergeinfo!==undefined&&type!=='svn')throw new Error('Effective SVN mergeinfo requires SVN repository');
|
|
52
|
-
if (['svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnBasePath','svnTargetPath'].some(key=>options[key]!==undefined)) {
|
|
53
|
-
if (type!=='svn') throw new Error('SVN revision parameters require SVN working copy');
|
|
54
|
-
return bindBusinessRequirement(await captureSvnHistory(cwd,options),options.businessRequirement);
|
|
65
|
+
if (options.svnEffectiveMergeinfo !== undefined && type !== 'svn') throw new Error('Effective SVN mergeinfo requires SVN repository');
|
|
66
|
+
if (['svnRevision', 'svnBaseRevision', 'svnTargetRevision', 'svnAllowNetwork', 'svnBasePath', 'svnTargetPath'].some(key => options[key] !== undefined)) {
|
|
67
|
+
if (type !== 'svn') throw new Error('SVN revision parameters require SVN working copy');
|
|
68
|
+
return bindBusinessRequirement(await captureSvnHistory(cwd, options), options.businessRequirement);
|
|
55
69
|
}
|
|
56
|
-
if (options.oldContextPaths !== undefined && (type !== 'git' || !['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined))) throw new Error('Old context requires Git historical review');
|
|
57
|
-
if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
|
|
70
|
+
if (options.oldContextPaths !== undefined && (type !== 'git' || !['commit', 'baseRevision', 'targetRevision'].some(k => options[k] !== undefined))) throw new Error('Old context requires Git historical review');
|
|
71
|
+
if (['commit', 'baseRevision', 'targetRevision'].some(k => options[k] !== undefined)) {
|
|
58
72
|
if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
|
|
59
|
-
return bindBusinessRequirement(await captureGitHistory(cwd, options),options.businessRequirement);
|
|
73
|
+
return bindBusinessRequirement(await captureGitHistory(cwd, options), options.businessRequirement);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (type === 'svn') {
|
|
77
|
+
const budget = createSvnCaptureBudget(options.signal);
|
|
78
|
+
options = { ...options, signal: budget.signal, svnCaptureBudget: budget };
|
|
60
79
|
}
|
|
61
|
-
if(type==='svn'){const budget=createSvnCaptureBudget(options.signal);options={...options,signal:budget.signal,svnCaptureBudget:budget};}
|
|
62
80
|
const capture = type === 'git' ? captureGit : captureSvn;
|
|
63
81
|
const snapshot = await capture(cwd, options);
|
|
64
82
|
if (!Array.isArray(rulePaths) || rulePaths.some(name => contextPaths.includes(name))) throw new Error('Rule paths must be separate from context paths');
|
|
65
83
|
const rules = await captureProjectRules(snapshot.root, rulePaths, { signal: options.signal, files: snapshot.files });
|
|
66
|
-
const navigationBudget=type==='svn'?createSvnNavigationBudget(options.signal):undefined;
|
|
67
|
-
const navigationOptions=navigationBudget?{...options,signal:navigationBudget.signal,svnNavigationBudget:navigationBudget}:options;
|
|
84
|
+
const navigationBudget = type === 'svn' ? createSvnNavigationBudget(options.signal) : undefined;
|
|
85
|
+
const navigationOptions = navigationBudget ? { ...options, signal: navigationBudget.signal, svnNavigationBudget: navigationBudget } : options;
|
|
86
|
+
|
|
68
87
|
const context = [];
|
|
69
88
|
let autoContext, index, verifyDependencies, explicitSeeds = [], indexOptions = options;
|
|
70
|
-
if (options.autoContext === true && type==='git') {
|
|
71
|
-
const chain=await captureDependencyChain(snapshot.root,snapshot.files,options);
|
|
72
|
-
explicitSeeds=chain.explicitSeeds;
|
|
89
|
+
if (options.autoContext === true && type === 'git') {
|
|
90
|
+
const chain = await captureDependencyChain(snapshot.root, snapshot.files, options);
|
|
91
|
+
explicitSeeds = chain.explicitSeeds;
|
|
92
|
+
context.push(...chain.context);
|
|
93
|
+
autoContext = chain.autoContext;
|
|
94
|
+
index = chain.index;
|
|
95
|
+
indexOptions = chain.indexOptions;
|
|
73
96
|
} else if (options.autoContext === true) {
|
|
74
|
-
const chain=await captureSvnDependencyChain(snapshot.root,snapshot.files,navigationOptions);
|
|
75
|
-
explicitSeeds=chain.explicitSeeds;
|
|
97
|
+
const chain = await captureSvnDependencyChain(snapshot.root, snapshot.files, navigationOptions);
|
|
98
|
+
explicitSeeds = chain.explicitSeeds;
|
|
99
|
+
context.push(...chain.context);
|
|
100
|
+
autoContext = chain.autoContext;
|
|
101
|
+
indexOptions = chain.indexOptions;
|
|
102
|
+
verifyDependencies = chain.verify;
|
|
76
103
|
}
|
|
77
104
|
let verifyExplicit;
|
|
78
|
-
if(type==='svn'&&options.autoContext!==true&&contextPaths.length){
|
|
79
|
-
const explicit=await captureSvnExplicitContext(snapshot.root,[...new Set(contextPaths)].filter(name
|
|
80
|
-
explicitSeeds=explicit.context;
|
|
105
|
+
if (type === 'svn' && options.autoContext !== true && contextPaths.length) {
|
|
106
|
+
const explicit = await captureSvnExplicitContext(snapshot.root, [...new Set(contextPaths)].filter(name => !snapshot.files.some(f => f.path === name)).sort(), navigationOptions);
|
|
107
|
+
explicitSeeds = explicit.context;
|
|
108
|
+
verifyExplicit = explicit.verify;
|
|
109
|
+
indexOptions = explicit.options;
|
|
81
110
|
}
|
|
111
|
+
|
|
82
112
|
for (const name of [...new Set(contextPaths)].sort()) {
|
|
83
113
|
const changed = snapshot.files.find(f => f.path === name);
|
|
84
114
|
if (changed) {
|
|
85
115
|
if (changed.eligibility !== 'reviewable') throw new Error('Context path is an excluded or blocked change');
|
|
86
116
|
if (changed.rightExists === false) throw new Error('Deleted path cannot be working context');
|
|
87
|
-
if(changed.specialData)throw new Error('SVN link records cannot be working source context');
|
|
88
|
-
context.push({ path: name, ...changed.right
|
|
117
|
+
if (changed.specialData) throw new Error('SVN link records cannot be working source context');
|
|
118
|
+
context.push({ path: name, ...changed.right, ...(changed.eolMapping?.new ? { eolMapping: structuredClone(changed.eolMapping.new) } : {}) });
|
|
89
119
|
} else {
|
|
90
|
-
const seed=explicitSeeds.find(c=>c.path===name);
|
|
91
|
-
const item=type==='svn'&&options.autoContext!==true&&seed?structuredClone(seed):{path:name
|
|
92
|
-
if(seed&&seed.hash!==item.hash)throw new Error('Explicit dependency seed changed during capture');
|
|
93
|
-
if(seed?.eolMapping)item.eolMapping=structuredClone(seed.eolMapping);
|
|
94
|
-
if(seed?.keywordMapping)item.keywordMapping=structuredClone(seed.keywordMapping);
|
|
120
|
+
const seed = explicitSeeds.find(c => c.path === name);
|
|
121
|
+
const item = type === 'svn' && options.autoContext !== true && seed ? structuredClone(seed) : { path: name, ...await readLocal(snapshot.root, name, indexOptions) };
|
|
122
|
+
if (seed && seed.hash !== item.hash) throw new Error('Explicit dependency seed changed during capture');
|
|
123
|
+
if (seed?.eolMapping) item.eolMapping = structuredClone(seed.eolMapping);
|
|
124
|
+
if (seed?.keywordMapping) item.keywordMapping = structuredClone(seed.keywordMapping);
|
|
95
125
|
context.push(item);
|
|
96
126
|
}
|
|
97
127
|
}
|
|
128
|
+
|
|
98
129
|
let callerDiscovery, verifyCallers;
|
|
99
130
|
if (options.callerScopePaths !== undefined) {
|
|
100
|
-
const callers = await (type==='git'?captureCallers:captureSvnCallers)(snapshot.root, snapshot.files, options.callerScopePaths, type==='svn'?navigationOptions:options);
|
|
131
|
+
const callers = await (type === 'git' ? captureCallers : captureSvnCallers)(snapshot.root, snapshot.files, options.callerScopePaths, type === 'svn' ? navigationOptions : options);
|
|
101
132
|
verifyCallers = callers.verify;
|
|
102
133
|
callerDiscovery = { ...callers.discovery, capturedPaths: [] };
|
|
103
134
|
for (const item of callers.context) {
|
|
104
135
|
const present = context.find(c => c.path === item.path);
|
|
105
136
|
const matches = callerDiscovery.candidates.filter(c => c.fromPath === item.path);
|
|
106
137
|
if (present && present.hash !== item.hash) throw new Error('Caller context changed during capture');
|
|
107
|
-
if (!present && context.length >= Math.min(maxContextFiles,20)) {
|
|
108
|
-
for (const candidate of matches) { candidate.status='blocked'; candidate.captureReason='Context file limit exceeded'; }
|
|
138
|
+
if (!present && context.length >= Math.min(maxContextFiles, 20)) {
|
|
139
|
+
for (const candidate of matches) { candidate.status = 'blocked'; candidate.captureReason = 'Context file limit exceeded'; }
|
|
109
140
|
continue;
|
|
110
141
|
}
|
|
111
142
|
if (!present) context.push(item);
|
|
112
143
|
callerDiscovery.capturedPaths.push(item.path);
|
|
113
|
-
for (const candidate of matches) candidate.status='captured';
|
|
144
|
+
for (const candidate of matches) candidate.status = 'captured';
|
|
114
145
|
}
|
|
115
146
|
}
|
|
147
|
+
|
|
116
148
|
// Re-capture verifies both selected sides while collecting explicit context.
|
|
117
149
|
if ((await capture(cwd, indexOptions)).id !== snapshot.id) throw new Error('Snapshot changed while collecting context');
|
|
118
150
|
for (const item of context) if ((await readLocal(snapshot.root, item.path, indexOptions)).hash !== item.hash) throw new Error('Context changed during capture');
|
|
@@ -122,9 +154,10 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
122
154
|
if (verifyExplicit) await verifyExplicit();
|
|
123
155
|
if (verifyDependencies) await verifyDependencies();
|
|
124
156
|
if (verifyCallers) await verifyCallers();
|
|
125
|
-
|
|
157
|
+
|
|
158
|
+
const result = { ...snapshot, context, ...(callerDiscovery ? { callerDiscovery } : {}), ...(autoContext ? { autoContext } : {}), ...(rules.length ? { rules } : {}) };
|
|
126
159
|
delete result.id;
|
|
127
160
|
const serialized = JSON.stringify(result);
|
|
128
161
|
if (Buffer.byteLength(serialized) > maxSnapshotBytes) throw new Error('Snapshot size limit exceeded');
|
|
129
|
-
return bindBusinessRequirement({ ...result, id: hash(serialized) },options.businessRequirement);
|
|
162
|
+
return bindBusinessRequirement({ ...result, id: hash(serialized) }, options.businessRequirement);
|
|
130
163
|
}
|
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
import {captureSignal} from './capture-signal.mjs';
|
|
1
|
+
import { captureSignal } from './capture-signal.mjs';
|
|
2
|
+
|
|
2
3
|
// Shared across initial capture, navigation, mergeinfo and final verification.
|
|
3
|
-
export function createSvnCaptureBudget(signal){
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export function createSvnCaptureBudget(signal) {
|
|
5
|
+
const deadline = captureSignal(signal);
|
|
6
|
+
let commands = 0, metadataBytes = 0, bodyBytes = 0;
|
|
7
|
+
const budgetError = message => Object.assign(new Error(message), { code: 'SVN_CAPTURE_BUDGET' });
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
signal: deadline,
|
|
10
|
+
reserve() { deadline.throwIfAborted(); commands++; },
|
|
11
|
+
charge(size, body = false) {
|
|
12
|
+
deadline.throwIfAborted();
|
|
13
|
+
if (!Number.isSafeInteger(size) || size < 0) throw budgetError('Invalid SVN capture byte charge');
|
|
14
|
+
if (body) {
|
|
15
|
+
bodyBytes += size;
|
|
16
|
+
if (bodyBytes > 16 * 1024 * 1024) throw budgetError('SVN capture shared verification body budget exceeded');
|
|
17
|
+
} else {
|
|
18
|
+
metadataBytes += size;
|
|
19
|
+
if (metadataBytes > 1024 * 1024) throw budgetError('SVN capture shared metadata budget exceeded');
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
usage() { return { commands, metadataBytes, bodyBytes }; },
|
|
23
|
+
});
|
|
7
24
|
}
|
|
@@ -1,46 +1,68 @@
|
|
|
1
|
-
import {hash,relativePath} from './content.mjs';
|
|
2
|
-
import {mergeinfoNavigation} from './svn-mergeinfo.mjs';
|
|
3
|
-
|
|
1
|
+
import { hash, relativePath } from './content.mjs';
|
|
2
|
+
import { mergeinfoNavigation } from './svn-mergeinfo.mjs';
|
|
3
|
+
|
|
4
|
+
const NOTICE = 'Effective property navigation within the evidenced ancestor boundary only; not merge attribution, reviewed revision coverage, permission to read branch bodies, or proof of merge execution.';
|
|
4
5
|
|
|
5
6
|
// records are nearest-first and complete from target to the declared boundary.
|
|
6
7
|
// present:false is proven absence; missing records must never mean absent.
|
|
7
|
-
export function effectiveMergeinfo(target,records,{boundary='.',complete=false}={}){
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
evidence
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
8
|
+
export function effectiveMergeinfo(target, records, { boundary = '.', complete = false } = {}) {
|
|
9
|
+
if (target !== '.') relativePath(target);
|
|
10
|
+
if (boundary !== '.') relativePath(boundary);
|
|
11
|
+
if (boundary !== '.' && target !== boundary && !target.startsWith(boundary + '/')) throw new Error('Mergeinfo boundary is not an ancestor');
|
|
12
|
+
|
|
13
|
+
const unknown = reason => ({ status: 'unknown', reason, sources: [], notice: NOTICE });
|
|
14
|
+
if (!Array.isArray(records) || records.length > 33) return unknown('Ancestor evidence missing or depth limit exceeded');
|
|
15
|
+
|
|
16
|
+
let cursor = target;
|
|
17
|
+
const evidence = [];
|
|
18
|
+
for (const record of records) {
|
|
19
|
+
if (!record || record.path !== cursor || typeof record.present !== 'boolean' || typeof record.identity !== 'string' || !record.identity) return unknown('Incomplete or unordered ancestor provenance');
|
|
20
|
+
if (record.present && typeof record.value !== 'string') return unknown('Mergeinfo value is not captured text');
|
|
21
|
+
evidence.push({ path: cursor, identity: record.identity, present: record.present, ...(record.present ? { propertyHash: hash(record.value) } : {}) });
|
|
22
|
+
|
|
23
|
+
if (record.present) {
|
|
24
|
+
const parsed = mergeinfoNavigation(record.value);
|
|
25
|
+
if (parsed.status !== 'parsed') return { ...parsed, evidence, notice: NOTICE };
|
|
26
|
+
const inherited = cursor !== target;
|
|
27
|
+
const suffix = cursor === '.' ? (target === '.' ? '' : target) : target.slice(cursor.length + 1);
|
|
28
|
+
const sources = parsed.sources
|
|
29
|
+
.map(source => ({
|
|
30
|
+
sourcePath: inherited ? (source.sourcePath === '/' ? '' : source.sourcePath) + '/' + suffix : source.sourcePath,
|
|
31
|
+
ranges: source.ranges.filter(range => !inherited || range.inheritable),
|
|
32
|
+
}))
|
|
33
|
+
.filter(source => source.ranges.length);
|
|
34
|
+
if (sources.some(s => s.sourcePath.length > 1024)) return { status: 'limited', reason: 'Inherited mergeinfo source path limit exceeded', sources: [], evidence, notice: NOTICE };
|
|
35
|
+
return { status: 'parsed', origin: cursor, inherited, explicitEmpty: record.value === '', sources, evidence, hash: hash(JSON.stringify(evidence)), notice: NOTICE };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (cursor === boundary) {
|
|
39
|
+
if (!complete) return { ...unknown('Ancestor boundary does not prove repository-wide absence'), evidence, hash: hash(JSON.stringify(evidence)) };
|
|
40
|
+
return { status: 'parsed', origin: null, inherited: false, sources: [], evidence, hash: hash(JSON.stringify(evidence)), notice: NOTICE };
|
|
41
|
+
}
|
|
42
|
+
if (cursor === '.') return unknown('Declared ancestor boundary was not reached');
|
|
43
|
+
cursor = cursor.includes('/') ? cursor.slice(0, cursor.lastIndexOf('/')) : '.';
|
|
23
44
|
}
|
|
24
|
-
|
|
25
|
-
if(cursor==='.')return unknown('Declared ancestor boundary was not reached');
|
|
26
|
-
cursor=cursor.includes('/')?cursor.slice(0,cursor.lastIndexOf('/')):'.';
|
|
27
|
-
}
|
|
28
|
-
return unknown('Ancestor evidence ended before nearest explicit property or boundary');
|
|
45
|
+
return unknown('Ancestor evidence ended before nearest explicit property or boundary');
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
// Injected reader must freeze UUID/URL/peg/operative revision and charge its
|
|
32
49
|
// existing command/metadata budget. This helper never reads branch bodies.
|
|
33
|
-
export async function captureEffectiveMergeinfo(target,{read,signal,boundary='.',complete=false}={}){
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
export async function captureEffectiveMergeinfo(target, { read, signal, boundary = '.', complete = false } = {}) {
|
|
51
|
+
if (typeof read !== 'function') throw new Error('Fixed ancestor property reader required');
|
|
52
|
+
if (target !== '.') relativePath(target);
|
|
53
|
+
if (boundary !== '.') relativePath(boundary);
|
|
54
|
+
if (boundary !== '.' && target !== boundary && !target.startsWith(boundary + '/')) throw new Error('Mergeinfo boundary is not an ancestor');
|
|
55
|
+
const records = [];
|
|
56
|
+
let cursor = target;
|
|
57
|
+
for (let depth = 0; depth < 33; depth++) {
|
|
58
|
+
signal?.throwIfAborted();
|
|
59
|
+
const record = await read(cursor);
|
|
60
|
+
signal?.throwIfAborted();
|
|
61
|
+
if (record?.path !== cursor) throw new Error('Mergeinfo ancestor reader identity mismatch');
|
|
62
|
+
records.push(record);
|
|
63
|
+
if (record.present || cursor === boundary) return effectiveMergeinfo(target, records, { boundary, complete });
|
|
64
|
+
if (cursor === '.') throw new Error('Mergeinfo ancestor boundary not reached');
|
|
65
|
+
cursor = cursor.includes('/') ? cursor.slice(0, cursor.lastIndexOf('/')) : '.';
|
|
66
|
+
}
|
|
67
|
+
return { status: 'limited', reason: 'Mergeinfo ancestor depth limit exceeded', sources: [], notice: NOTICE };
|
|
46
68
|
}
|
package/src/svn-eol-mapping.mjs
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
import {hash} from './content.mjs';
|
|
1
|
+
import { hash } from './content.mjs';
|
|
2
2
|
|
|
3
3
|
// Keep raw text as evidence. LF view is metadata only, never a source replacement.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
const ALLOWED_STYLES = [null, 'native', 'LF', 'CRLF', 'CR'];
|
|
5
|
+
|
|
6
|
+
export function svnEolMapping(text, style = null) {
|
|
7
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > 256 * 1024 || !ALLOWED_STYLES.includes(style)) throw new Error('Unsupported SVN EOL mapping input or style');
|
|
8
|
+
let crlf = 0, lf = 0, cr = 0;
|
|
9
|
+
for (let i = 0; i < text.length; i++) {
|
|
10
|
+
if (text[i] === '\r') {
|
|
11
|
+
if (text[i + 1] === '\n') { crlf++; i++; }
|
|
12
|
+
else cr++;
|
|
13
|
+
} else if (text[i] === '\n') lf++;
|
|
14
|
+
}
|
|
15
|
+
const normalized = text.replace(/\r\n|\r/g, '\n');
|
|
16
|
+
return {
|
|
17
|
+
rawTextHash: hash(text),
|
|
18
|
+
lfViewHash: hash(normalized),
|
|
19
|
+
style,
|
|
20
|
+
counts: { crlf, lf, cr },
|
|
21
|
+
mixed: [crlf, lf, cr].filter(Boolean).length > 1,
|
|
22
|
+
lineMapping: cr ? 'not-identity-for-LF-evidence' : 'identity',
|
|
23
|
+
notice: 'Hashes describe decoded text encoded as UTF-8, not original source bytes. LF-view hash only; raw text and raw evidence line numbering remain authoritative. Lone CR changes LF line boundaries; do not use normalized line numbers. Newline-only differences do not independently prove a defect or runtime equivalence.',
|
|
24
|
+
};
|
|
13
25
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
import {relativePath} from './content.mjs';
|
|
2
|
-
import {assertNonSecretPath} from './secret-path.mjs';
|
|
1
|
+
import { relativePath } from './content.mjs';
|
|
2
|
+
import { assertNonSecretPath } from './secret-path.mjs';
|
|
3
3
|
|
|
4
4
|
// Cross-branch endpoints are repository-relative paths, never arbitrary URLs.
|
|
5
|
-
export function svnHistoryEndpoints(local,options,destination){
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
export function svnHistoryEndpoints(local, options, destination) {
|
|
6
|
+
const hasBase = options.svnBasePath !== undefined, hasTarget = options.svnTargetPath !== undefined;
|
|
7
|
+
if (hasBase !== hasTarget) throw new Error('SVN branch comparison requires both svnBasePath and svnTargetPath');
|
|
8
|
+
if (!hasBase) return { baseUrl: destination(local.url, options.svnAllowNetwork).url, targetUrl: destination(local.url, options.svnAllowNetwork).url, crossUrl: false };
|
|
9
|
+
if (options.svnRevision !== undefined) throw new Error('SVN branch paths require explicit revision range');
|
|
10
|
+
if (!local.repository?.root || !local.repository?.uuid) throw new Error('SVN repository root identity required');
|
|
11
|
+
|
|
12
|
+
const endpoint = value => {
|
|
13
|
+
if (value !== '.') { relativePath(value); assertNonSecretPath(value); }
|
|
14
|
+
const root = destination(local.repository.root, options.svnAllowNetwork).url;
|
|
15
|
+
return value === '.' ? root : root + '/' + value.split('/').map(encodeURIComponent).join('/');
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const baseUrl = endpoint(options.svnBasePath), targetUrl = endpoint(options.svnTargetPath);
|
|
19
|
+
if (baseUrl !== targetUrl && (baseUrl.startsWith(targetUrl + '/') || targetUrl.startsWith(baseUrl + '/'))) throw new Error('Overlapping SVN branch roots require unambiguous endpoint path mapping');
|
|
20
|
+
return { baseUrl, targetUrl, crossUrl: baseUrl !== targetUrl };
|
|
15
21
|
}
|
|
@@ -1,31 +1,39 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {relativePath} from './content.mjs';
|
|
2
|
+
import { relativePath } from './content.mjs';
|
|
3
|
+
|
|
4
|
+
const REVISION = /^(0|[1-9][0-9]*)$/;
|
|
3
5
|
|
|
4
6
|
// A successful immediate-parent listing proves absence within the visible scope.
|
|
5
7
|
// Access errors are propagated, not interpreted using stderr or localized text.
|
|
6
|
-
export function fixedSvnLookup({list,signal,maxEntries=512}={}) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
};
|
|
30
|
-
|
|
8
|
+
export function fixedSvnLookup({ list, signal, maxEntries = 512 } = {}) {
|
|
9
|
+
if (typeof list !== 'function' || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || maxEntries > 512) throw new Error('Invalid SVN historical lookup capability');
|
|
10
|
+
const directories = new Map();
|
|
11
|
+
let entries = 0;
|
|
12
|
+
|
|
13
|
+
const lookup = async (name, revision, side) => {
|
|
14
|
+
relativePath(name);
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
if (typeof revision !== 'string' || !REVISION.test(revision) || !Number.isSafeInteger(Number(revision))) throw new Error('Invalid SVN lookup revision');
|
|
17
|
+
const parent = path.posix.dirname(name);
|
|
18
|
+
const key = JSON.stringify([side ?? null, revision, parent]);
|
|
19
|
+
if (!directories.has(key)) {
|
|
20
|
+
if (parent !== '.') {
|
|
21
|
+
const node = await lookup(parent, revision, side);
|
|
22
|
+
if (!node || node.kind !== 'dir') return null;
|
|
23
|
+
}
|
|
24
|
+
const records = await list(parent, revision, side);
|
|
25
|
+
signal?.throwIfAborted();
|
|
26
|
+
if (!Array.isArray(records)) throw new Error('Invalid fixed SVN directory listing');
|
|
27
|
+
const children = new Map();
|
|
28
|
+
for (const record of records) {
|
|
29
|
+
if (++entries > maxEntries) throw new Error('SVN historical directory entry budget exceeded');
|
|
30
|
+
if (typeof record?.name !== 'string' || record.name.includes('/') || relativePath(record.name) !== record.name || !['file', 'dir'].includes(record.kind) || children.has(record.name)) throw new Error('Invalid fixed SVN directory entry');
|
|
31
|
+
children.set(record.name, { kind: record.kind });
|
|
32
|
+
}
|
|
33
|
+
directories.set(key, children);
|
|
34
|
+
}
|
|
35
|
+
return directories.get(key).get(path.posix.basename(name)) ?? null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return { lookup, usage: () => ({ directories: directories.size, entries }) };
|
|
31
39
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Native SVN separates keyword definitions using ASCII whitespace, not shell quotes.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
const CONTROL_CHARS = /[\x00-\x08\x0e-\x1f\x7f]/;
|
|
3
|
+
const WHITESPACE = /[ \t\r\n\v\f]+/;
|
|
4
|
+
|
|
5
|
+
export function keywordConfigurationTokens(configuration) {
|
|
6
|
+
if (typeof configuration !== 'string' || Buffer.byteLength(configuration) > 4096) throw new Error('Invalid SVN keyword configuration');
|
|
7
|
+
if (configuration.includes(String.fromCharCode(34)) || configuration.includes(String.fromCharCode(39))) throw new Error('Quoted SVN keyword configurations are unsupported; use %_ for a space');
|
|
8
|
+
if (CONTROL_CHARS.test(configuration)) throw new Error('Control character in SVN keyword configuration');
|
|
9
|
+
if (configuration.includes(String.fromCharCode(92))) throw new Error('Unquoted escape in SVN keyword configuration');
|
|
10
|
+
const tokens = configuration.split(WHITESPACE).filter(Boolean);
|
|
11
|
+
if (tokens.length > 100) throw new Error('SVN keyword configuration token limit exceeded');
|
|
12
|
+
return tokens;
|
|
10
13
|
}
|