@feng0531/dsh-code-review 0.83.0 → 0.83.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/RELEASE-NOTES.md +8 -7
  3. package/index.mjs +11 -12
  4. package/package.json +1 -1
  5. package/src/background-review.mjs +84 -12
  6. package/src/business-groups.mjs +1 -1
  7. package/src/class-bindings.mjs +45 -12
  8. package/src/defect-guidance.mjs +158 -16
  9. package/src/dsh-model.mjs +107 -15
  10. package/src/git-history.mjs +202 -56
  11. package/src/git.mjs +64 -34
  12. package/src/import-bindings.mjs +74 -65
  13. package/src/job-progress.mjs +21 -2
  14. package/src/model-contract.mjs +17 -1
  15. package/src/primary-input.mjs +85 -26
  16. package/src/project-report.mjs +22 -5
  17. package/src/project-review-service.mjs +177 -37
  18. package/src/project-snapshot.mjs +101 -40
  19. package/src/prompts.mjs +9 -0
  20. package/src/protocol-guidance.mjs +13 -1
  21. package/src/recovery-coverage.mjs +25 -1
  22. package/src/retrieval-loop.mjs +66 -28
  23. package/src/review.mjs +36 -33
  24. package/src/risk-plan.mjs +1 -1
  25. package/src/service.mjs +136 -5
  26. package/src/snapshot-reader.mjs +18 -5
  27. package/src/snapshot.mjs +69 -42
  28. package/src/svn-capture-budget.mjs +22 -5
  29. package/src/svn-effective-mergeinfo.mjs +59 -37
  30. package/src/svn-eol-mapping.mjs +22 -10
  31. package/src/svn-history-endpoints.mjs +18 -12
  32. package/src/svn-history-lookup.mjs +34 -26
  33. package/src/svn-keyword-config.mjs +11 -8
  34. package/src/svn-keyword-mapping.mjs +67 -36
  35. package/src/svn-mergeinfo.mjs +34 -22
  36. package/src/svn-navigation-budget.mjs +15 -4
  37. package/src/svn-special.mjs +13 -8
  38. package/src/svn-url.mjs +18 -6
  39. package/src/svn-working-identity.mjs +22 -8
  40. package/src/svn-working-scope.mjs +16 -5
  41. package/src/tool-view.mjs +5 -0
  42. package/src/verification-loop.mjs +2 -1
@@ -1,46 +1,68 @@
1
- import {hash,relativePath} from './content.mjs';
2
- import {mergeinfoNavigation} from './svn-mergeinfo.mjs';
3
- 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.';
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
- if(target!=='.')relativePath(target);if(boundary!=='.')relativePath(boundary);
9
- if(boundary!=='.'&&target!==boundary&&!target.startsWith(boundary+'/'))throw new Error('Mergeinfo boundary is not an ancestor');
10
- const unknown=reason=>({status:'unknown',reason,sources:[],notice});
11
- if(!Array.isArray(records)||records.length>33)return unknown('Ancestor evidence missing or depth limit exceeded');
12
- let cursor=target;const evidence=[];
13
- for(const record of records){
14
- if(!record||record.path!==cursor||typeof record.present!=='boolean'||typeof record.identity!=='string'||!record.identity)return unknown('Incomplete or unordered ancestor provenance');
15
- if(record.present&&typeof record.value!=='string')return unknown('Mergeinfo value is not captured text');
16
- evidence.push({path:cursor,identity:record.identity,present:record.present,...(record.present?{propertyHash:hash(record.value)}:{})});
17
- if(record.present){
18
- const parsed=mergeinfoNavigation(record.value);if(parsed.status!=='parsed')return {...parsed,evidence,notice};
19
- const inherited=cursor!==target,suffix=cursor==='.'?(target==='.'?'':target):target.slice(cursor.length+1);
20
- const sources=parsed.sources.map(source=>({sourcePath:inherited?(source.sourcePath==='/'?'':source.sourcePath)+'/'+suffix:source.sourcePath,ranges:source.ranges.filter(range=>!inherited||range.inheritable)})).filter(source=>source.ranges.length);
21
- if(sources.some(s=>s.sourcePath.length>1024))return {status:'limited',reason:'Inherited mergeinfo source path limit exceeded',sources:[],evidence,notice};
22
- return {status:'parsed',origin:cursor,inherited,explicitEmpty:record.value==='',sources,evidence,hash:hash(JSON.stringify(evidence)),notice};
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
- if(cursor===boundary){if(!complete)return {...unknown('Ancestor boundary does not prove repository-wide absence'),evidence,hash:hash(JSON.stringify(evidence))};return {status:'parsed',origin:null,inherited:false,sources:[],evidence,hash:hash(JSON.stringify(evidence)),notice};}
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
- if(typeof read!=='function')throw new Error('Fixed ancestor property reader required');
35
- if(target!=='.')relativePath(target);if(boundary!=='.')relativePath(boundary);
36
- if(boundary!=='.'&&target!==boundary&&!target.startsWith(boundary+'/'))throw new Error('Mergeinfo boundary is not an ancestor');
37
- const records=[];let cursor=target;
38
- for(let depth=0;depth<33;depth++){
39
- signal?.throwIfAborted();const record=await read(cursor);signal?.throwIfAborted();
40
- if(record?.path!==cursor)throw new Error('Mergeinfo ancestor reader identity mismatch');records.push(record);
41
- if(record.present||cursor===boundary)return effectiveMergeinfo(target,records,{boundary,complete});
42
- if(cursor==='.')throw new Error('Mergeinfo ancestor boundary not reached');
43
- cursor=cursor.includes('/')?cursor.slice(0,cursor.lastIndexOf('/')):'.';
44
- }
45
- return {status:'limited',reason:'Mergeinfo ancestor depth limit exceeded',sources:[],notice};
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
  }
@@ -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
- export function svnEolMapping(text,style=null){
5
- if(typeof text!=='string'||Buffer.byteLength(text)>256*1024||!(style===null||['native','LF','CRLF','CR'].includes(style)))throw new Error('Unsupported SVN EOL mapping input or style');
6
- let crlf=0,lf=0,cr=0;
7
- for(let i=0;i<text.length;i++){
8
- if(text[i]==='\r'){if(text[i+1]==='\n'){crlf++;i++;}else cr++;}
9
- else if(text[i]==='\n')lf++;
10
- }
11
- const normalized=text.replace(/\r\n|\r/g,'\n');
12
- return {rawTextHash:hash(text),lfViewHash:hash(normalized),style,counts:{crlf,lf,cr},mixed:[crlf,lf,cr].filter(Boolean).length>1,lineMapping:cr?'not-identity-for-LF-evidence':'identity',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.'};
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
- 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
- const endpoint=value=>{if(value!=='.'){relativePath(value);assertNonSecretPath(value);}const root=destination(local.repository.root,options.svnAllowNetwork).url;return value==='.'?root:root+'/'+value.split('/').map(encodeURIComponent).join('/');};
12
- const baseUrl=endpoint(options.svnBasePath),targetUrl=endpoint(options.svnTargetPath);
13
- if(baseUrl!==targetUrl&&(baseUrl.startsWith(targetUrl+'/')||targetUrl.startsWith(baseUrl+'/')))throw new Error('Overlapping SVN branch roots require unambiguous endpoint path mapping');
14
- return {baseUrl,targetUrl,crossUrl:baseUrl!==targetUrl};
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
- if(typeof list!=='function'||!Number.isSafeInteger(maxEntries)||maxEntries<1||maxEntries>512)throw new Error('Invalid SVN historical lookup capability');
8
- const directories=new Map();let entries=0;
9
- const lookup=async(name,revision,side)=>{
10
- relativePath(name);signal?.throwIfAborted();
11
- if(typeof revision!=='string'||!/^(0|[1-9][0-9]*)$/.test(revision)||!Number.isSafeInteger(Number(revision)))throw new Error('Invalid SVN lookup revision');
12
- const parent=path.posix.dirname(name),key=JSON.stringify([side??null,revision,parent]);
13
- if(!directories.has(key)){
14
- if(parent!=='.'){
15
- const node=await lookup(parent,revision,side);
16
- if(!node||node.kind!=='dir')return null;
17
- }
18
- const records=await list(parent,revision,side);signal?.throwIfAborted();
19
- if(!Array.isArray(records))throw new Error('Invalid fixed SVN directory listing');
20
- const children=new Map();
21
- for(const record of records){
22
- if(++entries>maxEntries)throw new Error('SVN historical directory entry budget exceeded');
23
- 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');
24
- children.set(record.name,{kind:record.kind});
25
- }
26
- directories.set(key,children);
27
- }
28
- return directories.get(key).get(path.posix.basename(name))??null;
29
- };
30
- return {lookup,usage:()=>({directories:directories.size,entries})};
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
- export function keywordConfigurationTokens(configuration){
3
- if(typeof configuration!=='string'||Buffer.byteLength(configuration)>4096)throw new Error('Invalid SVN keyword configuration');
4
- if(configuration.includes(String.fromCharCode(34))||configuration.includes(String.fromCharCode(39)))throw new Error('Quoted SVN keyword configurations are unsupported; use %_ for a space');
5
- if(/[\x00-\x08\x0e-\x1f\x7f]/.test(configuration))throw new Error('Control character in SVN keyword configuration');
6
- if(configuration.includes(String.fromCharCode(92)))throw new Error('Unquoted escape in SVN keyword configuration');
7
- const tokens=configuration.split(/[ \t\r\n\v\f]+/).filter(Boolean);
8
- if(tokens.length>100)throw new Error('SVN keyword configuration token limit exceeded');
9
- return tokens;
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
  }
@@ -1,44 +1,75 @@
1
- import {hash} from './content.mjs';
2
- import {keywordConfigurationTokens} from './svn-keyword-config.mjs';
3
- const standard=new Set(['Date','LastChangedDate','Revision','Rev','LastChangedRevision','Author','LastChangedBy','HeadURL','URL','Id','Header']);
1
+ import { hash } from './content.mjs';
2
+ import { keywordConfigurationTokens } from './svn-keyword-config.mjs';
3
+
4
+ const STANDARD = new Set(['Date', 'LastChangedDate', 'Revision', 'Rev', 'LastChangedRevision', 'Author', 'LastChangedBy', 'HeadURL', 'URL', 'Id', 'Header']);
5
+ const CUSTOM_DEFINITION = /^([A-Za-z][A-Za-z0-9]*)=(.+)$/;
6
+ const ALLOWED_FORMAT_FLAGS = 'radDuRPb_HI%';
7
+ const ALLOWED_LITERAL = /[A-Za-z0-9_.:/ ()-]/;
8
+ const EXPANSION = /\$([A-Za-z][A-Za-z0-9]*)(:{1,2})([^\r\n$]*)\$/g;
9
+ const CONTRACTED_FIXED = /\$([A-Za-z][A-Za-z0-9]*)::( +)\$/g;
10
+
11
+ function parseCustomFormat(format) {
12
+ for (let i = 0; i < format.length; i++) {
13
+ const c = format[i];
14
+ if (c === '%') {
15
+ if (!ALLOWED_FORMAT_FLAGS.includes(format[++i] ?? '') || i >= format.length) throw new Error('Unsupported SVN custom keyword format');
16
+ } else if (!ALLOWED_LITERAL.test(c)) {
17
+ throw new Error('Unsupported SVN custom keyword literal');
18
+ }
19
+ }
20
+ }
4
21
 
5
22
  // Parse bounded quoted and unquoted SVN formats. Expansion is verified by SVN BASE,
6
23
  // never reimplemented here; raw source remains the only evidence.
7
- export function svnKeywordConfiguration(configuration){
8
- if(typeof configuration!=='string'||Buffer.byteLength(configuration)>4096)throw new Error('Invalid SVN keyword configuration');
9
- const enabled=new Set(),custom=[];
10
- for(const token of keywordConfigurationTokens(configuration)){
11
- if(standard.has(token)){if(enabled.has(token))throw new Error('Duplicate SVN keyword configuration');enabled.add(token);continue;}
12
- const match=/^([A-Za-z][A-Za-z0-9]*)=(.+)$/.exec(token);
13
- if(!match||standard.has(match[1])||enabled.has(match[1]))throw new Error('Unsupported SVN custom keyword configuration');
14
- const format=match[2];
15
- for(let i=0;i<format.length;i++){
16
- const c=format[i];if(c==='%'){if(!'radDuRPb_HI%'.includes(format[++i]??'')||i>=format.length)throw new Error('Unsupported SVN custom keyword format');}
17
- else if(!/[A-Za-z0-9_.:/ ()-]/.test(c))throw new Error('Unsupported SVN custom keyword literal');
24
+ export function svnKeywordConfiguration(configuration) {
25
+ if (typeof configuration !== 'string' || Buffer.byteLength(configuration) > 4096) throw new Error('Invalid SVN keyword configuration');
26
+ const enabled = new Set(), custom = [];
27
+ for (const token of keywordConfigurationTokens(configuration)) {
28
+ if (STANDARD.has(token)) {
29
+ if (enabled.has(token)) throw new Error('Duplicate SVN keyword configuration');
30
+ enabled.add(token);
31
+ continue;
32
+ }
33
+ const match = CUSTOM_DEFINITION.exec(token);
34
+ if (!match || STANDARD.has(match[1]) || enabled.has(match[1])) throw new Error('Unsupported SVN custom keyword configuration');
35
+ parseCustomFormat(match[2]);
36
+ enabled.add(match[1]);
37
+ custom.push({ name: match[1], format: match[2] });
18
38
  }
19
- enabled.add(match[1]);custom.push({name:match[1],format});
20
- }
21
- return {enabled,custom};
39
+ return { enabled, custom };
22
40
  }
23
41
 
24
- export function svnKeywordMapping(text,configuration){
25
- if(typeof text!=='string'||Buffer.byteLength(text)>256*1024)throw new Error('Invalid SVN keyword mapping input');
26
- const {enabled,custom}=svnKeywordConfiguration(configuration);let replacements=0,fixedWidth=0;
27
- const normalized=text.replace(/\$([A-Za-z][A-Za-z0-9]*)(:{1,2})([^\r\n$]*)\$/g,(literal,name,separator,value)=>{
28
- if(!enabled.has(name))return literal;
29
- if(separator==='::'){
30
- // SVN reserves the complete field width, including the initial space and
31
- // final space (or # truncation marker); shrink values without changing width.
32
- if(value.length<2||value[0]!==' '||![' ','#'].includes(value.at(-1)))throw new Error('Ambiguous SVN fixed-width keyword expansion');
33
- fixedWidth++;if(/[^ ]/.test(value))replacements++;return '$'+name+'::'+' '.repeat(value.length)+'$';
42
+ export function svnKeywordMapping(text, configuration) {
43
+ if (typeof text !== 'string' || Buffer.byteLength(text) > 256 * 1024) throw new Error('Invalid SVN keyword mapping input');
44
+ const { enabled, custom } = svnKeywordConfiguration(configuration);
45
+ let replacements = 0, fixedWidth = 0;
46
+ const normalized = text.replace(EXPANSION, (literal, name, separator, value) => {
47
+ if (!enabled.has(name)) return literal;
48
+ if (separator === '::') {
49
+ // SVN reserves the complete field width, including the initial space and
50
+ // final space (or # truncation marker); shrink values without changing width.
51
+ if (value.length < 2 || value[0] !== ' ' || ![' ', '#'].includes(value.at(-1))) throw new Error('Ambiguous SVN fixed-width keyword expansion');
52
+ fixedWidth++;
53
+ if (/[^ ]/.test(value)) replacements++;
54
+ return '$' + name + '::' + ' '.repeat(value.length) + '$';
55
+ }
56
+ if (!/^ .+ $/.test(value)) throw new Error('Ambiguous SVN keyword expansion');
57
+ replacements++;
58
+ return '$' + name + '$';
59
+ });
60
+ // Fixed fields intentionally retain :: after contraction. Any other colon
61
+ // occurrence must be a complete recognized field, not silently partial data.
62
+ const residual = normalized.replace(CONTRACTED_FIXED, (literal, name) => enabled.has(name) ? '' : literal);
63
+ for (const name of enabled) {
64
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
65
+ if (new RegExp('\\$' + escaped + ':').test(residual)) throw new Error('Incomplete SVN keyword expansion mapping');
34
66
  }
35
- if(!/^ .+ $/.test(value))throw new Error('Ambiguous SVN keyword expansion');
36
- replacements++;return '$'+name+'$';
37
- });
38
- // Fixed fields intentionally retain :: after contraction. Any other colon
39
- // occurrence must be a complete recognized field, not silently partial data.
40
- const residual=normalized.replace(/\$([A-Za-z][A-Za-z0-9]*)::( +)\$/g,(literal,name)=>enabled.has(name)?'':literal);
41
- for(const name of enabled){const escaped=name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');if(new RegExp('\\$'+escaped+':').test(residual))throw new Error('Incomplete SVN keyword expansion mapping');}
42
- if(text.split('\n').length!==normalized.split('\n').length)throw new Error('SVN keyword mapping changed line identity');
43
- return {text:normalized,rawTextHash:hash(text),textHash:hash(normalized),replacements,...(fixedWidth?{fixedWidthFields:fixedWidth}:{}),...(custom.length?{customKeywords:custom}:{}),lineMapping:'identity',status:'candidate-only',notice:'Line-preserving keyword view only; fixed-field width retained. BASE contraction must be independently verified against svn cat --ignore-keywords. Raw source and original line numbers remain evidence, not behavioral equivalence.'};
67
+ if (text.split('\n').length !== normalized.split('\n').length) throw new Error('SVN keyword mapping changed line identity');
68
+ return {
69
+ text: normalized, rawTextHash: hash(text), textHash: hash(normalized), replacements,
70
+ ...(fixedWidth ? { fixedWidthFields: fixedWidth } : {}),
71
+ ...(custom.length ? { customKeywords: custom } : {}),
72
+ lineMapping: 'identity', status: 'candidate-only',
73
+ notice: 'Line-preserving keyword view only; fixed-field width retained. BASE contraction must be independently verified against svn cat --ignore-keywords. Raw source and original line numbers remain evidence, not behavioral equivalence.',
74
+ };
44
75
  }
@@ -1,32 +1,44 @@
1
- import {hash} from './content.mjs';
1
+ import { hash } from './content.mjs';
2
2
 
3
3
  export function snapshotMergeSources(files) {
4
- return files.filter(file=>file.eligibility==='reviewable').flatMap(file=>file.properties.filter(prop=>prop.name==='svn:mergeinfo').map(prop=>({path:file.path,old:mergeinfoNavigation(prop.old),new:mergeinfoNavigation(prop.new)})));
4
+ return files
5
+ .filter(file => file.eligibility === 'reviewable')
6
+ .flatMap(file => file.properties
7
+ .filter(prop => prop.name === 'svn:mergeinfo')
8
+ .map(prop => ({ path: file.path, old: mergeinfoNavigation(prop.old), new: mergeinfoNavigation(prop.new) })));
5
9
  }
6
10
 
11
+ const CONTROL_OR_BACKSLASH = /[\x00-\x1f\\]/;
12
+ const RANGE = /^([1-9][0-9]*)(?:-([1-9][0-9]*))?(\*)?$/;
13
+
7
14
  // Navigation over an already approved property. Never expands revisions or reads URLs.
8
15
  export function mergeinfoNavigation(text) {
9
- if(text===null || text==='')return {status:'parsed',sources:[],hash:hash(text??'')};
10
- if(typeof text!=='string')return {status:'invalid',reason:'Mergeinfo property is not text',sources:[]};
11
- if(Buffer.byteLength(text)>16*1024)return {status:'limited',reason:'Mergeinfo navigation byte limit exceeded',sources:[],hash:hash(text)};
12
- const lines=text.split(/\r?\n/);if(lines.at(-1)==='')lines.pop();
13
- if(lines.length>100)return {status:'limited',reason:'Mergeinfo navigation source limit exceeded',sources:[],hash:hash(text)};
14
- const sources=[],seen=new Set();let rangeCount=0;
15
- for(const line of lines){
16
- const delimiter=line.lastIndexOf(':');
17
- const sourcePath=line.slice(0,delimiter),list=line.slice(delimiter+1);
18
- if(delimiter<1||!sourcePath.startsWith('/')||/[\x00-\x1f\\]/.test(sourcePath)||sourcePath.length>1024||(sourcePath!=='/'&&sourcePath.slice(1).split('/').some(part=>!part||part==='.'||part==='..'))||seen.has(sourcePath))return {status:'invalid',reason:'Mergeinfo navigation path or duplicate source invalid',sources:[],hash:hash(text)};
16
+ if (text === null || text === '') return { status: 'parsed', sources: [], hash: hash(text ?? '') };
17
+ if (typeof text !== 'string') return { status: 'invalid', reason: 'Mergeinfo property is not text', sources: [] };
18
+ if (Buffer.byteLength(text) > 16 * 1024) return { status: 'limited', reason: 'Mergeinfo navigation byte limit exceeded', sources: [], hash: hash(text) };
19
+ const lines = text.split(/\r?\n/);
20
+ if (lines.at(-1) === '') lines.pop();
21
+ if (lines.length > 100) return { status: 'limited', reason: 'Mergeinfo navigation source limit exceeded', sources: [], hash: hash(text) };
22
+
23
+ const sources = [], seen = new Set();
24
+ let rangeCount = 0;
25
+ for (const line of lines) {
26
+ const delimiter = line.lastIndexOf(':');
27
+ const sourcePath = line.slice(0, delimiter), list = line.slice(delimiter + 1);
28
+ if (delimiter < 1 || !sourcePath.startsWith('/') || CONTROL_OR_BACKSLASH.test(sourcePath) || sourcePath.length > 1024 || (sourcePath !== '/' && sourcePath.slice(1).split('/').some(part => !part || part === '.' || part === '..')) || seen.has(sourcePath)) {
29
+ return { status: 'invalid', reason: 'Mergeinfo navigation path or duplicate source invalid', sources: [], hash: hash(text) };
30
+ }
19
31
  seen.add(sourcePath);
20
- const ranges=[];
21
- for(const token of list.split(',')){
22
- if(++rangeCount>500)return {status:'limited',reason:'Mergeinfo navigation range limit exceeded',sources:[],hash:hash(text)};
23
- const match=/^([1-9][0-9]*)(?:-([1-9][0-9]*))?(\*)?$/.exec(token);
24
- if(!match)return {status:'invalid',reason:'Mergeinfo navigation revision range invalid',sources:[],hash:hash(text)};
25
- const start=Number(match[1]),end=Number(match[2]??match[1]);
26
- if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start>end)return {status:'invalid',reason:'Mergeinfo navigation revision range invalid',sources:[],hash:hash(text)};
27
- ranges.push({start:String(start),end:String(end),inheritable:!match[3]});
32
+ const ranges = [];
33
+ for (const token of list.split(',')) {
34
+ if (++rangeCount > 500) return { status: 'limited', reason: 'Mergeinfo navigation range limit exceeded', sources: [], hash: hash(text) };
35
+ const match = RANGE.exec(token);
36
+ if (!match) return { status: 'invalid', reason: 'Mergeinfo navigation revision range invalid', sources: [], hash: hash(text) };
37
+ const start = Number(match[1]), end = Number(match[2] ?? match[1]);
38
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end) return { status: 'invalid', reason: 'Mergeinfo navigation revision range invalid', sources: [], hash: hash(text) };
39
+ ranges.push({ start: String(start), end: String(end), inheritable: !match[3] });
28
40
  }
29
- sources.push({sourcePath,ranges});
41
+ sources.push({ sourcePath, ranges });
30
42
  }
31
- return {status:'parsed',sources,hash:hash(text),notice:'Explicit mergeinfo navigation only; not effective inherited mergeinfo, change attribution, permission to read a source or evidence that listed revisions were reviewed.'};
43
+ return { status: 'parsed', sources, hash: hash(text), notice: 'Explicit mergeinfo navigation only; not effective inherited mergeinfo, change attribution, permission to read a source or evidence that listed revisions were reviewed.' };
32
44
  }
@@ -1,6 +1,17 @@
1
- import {captureSignal} from './capture-signal.mjs';
1
+ import { captureSignal } from './capture-signal.mjs';
2
+
2
3
  // One source ledger/deadline per working-copy navigation capture, never per stage.
3
- export function createSvnNavigationBudget(signal){
4
- const sharedSignal=captureSignal(signal);let bytes=0;
5
- return Object.freeze({signal:sharedSignal,charge(size){sharedSignal.throwIfAborted();if(!Number.isSafeInteger(size)||size<0)throw new Error('Invalid SVN navigation source size');bytes+=size;if(bytes>1024*1024)throw Object.assign(new Error('SVN shared navigation source byte limit exceeded'),{code:'SVN_NAVIGATION_BUDGET'});},usage(){return bytes;}});
4
+ export function createSvnNavigationBudget(signal) {
5
+ const sharedSignal = captureSignal(signal);
6
+ let bytes = 0;
7
+ return Object.freeze({
8
+ signal: sharedSignal,
9
+ charge(size) {
10
+ sharedSignal.throwIfAborted();
11
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error('Invalid SVN navigation source size');
12
+ bytes += size;
13
+ if (bytes > 1024 * 1024) throw Object.assign(new Error('SVN shared navigation source byte limit exceeded'), { code: 'SVN_NAVIGATION_BUDGET' });
14
+ },
15
+ usage() { return bytes; },
16
+ });
6
17
  }
@@ -1,10 +1,15 @@
1
1
  // SVN stores symbolic links as file-node data. Never resolve or open the target.
2
- export function svnSpecialData(content,properties,kind){
3
- if(!Object.hasOwn(properties,'svn:special'))return null;
4
- if(kind!=='file'||properties['svn:special']!=='*')throw new Error('Unsupported SVN special node kind or property value');
5
- if(Object.hasOwn(properties,'svn:keywords')||Object.hasOwn(properties,'svn:eol-style'))throw new Error('SVN special node has incompatible text transformation properties');
6
- if(content.encoding!=='utf-8'||!content.text.startsWith('link '))throw new Error('SVN special node is not a UTF-8 link record');
7
- const target=content.text.slice(5);
8
- if(!target||/[\x00-\x1f\x7f]/.test(target)||Buffer.byteLength(target)>16384)throw new Error('Invalid or oversized SVN link target data');
9
- return {type:'symlink',target,recordHash:content.hash,followed:false,notice:'Repository link target data only. Target text is untrusted data, not an authorized path, source file, existence check or navigation instruction. Never follow this link.'};
2
+ const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
3
+
4
+ export function svnSpecialData(content, properties, kind) {
5
+ if (!Object.hasOwn(properties, 'svn:special')) return null;
6
+ if (kind !== 'file' || properties['svn:special'] !== '*') throw new Error('Unsupported SVN special node kind or property value');
7
+ if (Object.hasOwn(properties, 'svn:keywords') || Object.hasOwn(properties, 'svn:eol-style')) throw new Error('SVN special node has incompatible text transformation properties');
8
+ if (content.encoding !== 'utf-8' || !content.text.startsWith('link ')) throw new Error('SVN special node is not a UTF-8 link record');
9
+ const target = content.text.slice(5);
10
+ if (!target || CONTROL_CHARS.test(target) || Buffer.byteLength(target) > 16384) throw new Error('Invalid or oversized SVN link target data');
11
+ return {
12
+ type: 'symlink', target, recordHash: content.hash, followed: false,
13
+ notice: 'Repository link target data only. Target text is untrusted data, not an authorized path, source file, existence check or navigation instruction. Never follow this link.',
14
+ };
10
15
  }
package/src/svn-url.mjs CHANGED
@@ -1,8 +1,20 @@
1
1
  // Compare repository URL spelling without decoding path separators into structure.
2
- export function canonicalSvnUrl(value){
3
- if(typeof value!=='string')throw new Error('Missing SVN URL');const url=new URL(value);
4
- if(url.username||url.password||url.search||url.hash||!['file:','http:','https:','svn:','svn+ssh:'].includes(url.protocol))throw new Error('Unsafe SVN URL identity');
5
- const segments=url.pathname.split('/').map(segment=>{const decoded=decodeURIComponent(segment);if(decoded.includes('/')||decoded.includes('\\')||/[\x00-\x1f\x7f]/.test(decoded)||decoded==='.'||decoded==='..')throw new Error('Unsafe encoded SVN URL segment');return encodeURIComponent(decoded);});
6
- return url.protocol+'//'+url.host+segments.join('/').replace(/\/$/,'');
2
+ const SAFE_PROTOCOLS = ['file:', 'http:', 'https:', 'svn:', 'svn+ssh:'];
3
+ const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
4
+
5
+ export function canonicalSvnUrl(value) {
6
+ if (typeof value !== 'string') throw new Error('Missing SVN URL');
7
+ const url = new URL(value);
8
+ if (url.username || url.password || url.search || url.hash || !SAFE_PROTOCOLS.includes(url.protocol)) throw new Error('Unsafe SVN URL identity');
9
+ const segments = url.pathname.split('/').map(segment => {
10
+ const decoded = decodeURIComponent(segment);
11
+ if (decoded.includes('/') || decoded.includes('\\') || CONTROL_CHARS.test(decoded) || decoded === '.' || decoded === '..') throw new Error('Unsafe encoded SVN URL segment');
12
+ return encodeURIComponent(decoded);
13
+ });
14
+ return url.protocol + '//' + url.host + segments.join('/').replace(/\/$/, '');
15
+ }
16
+
17
+ export function sameSvnUrl(a, b) {
18
+ try { return canonicalSvnUrl(a) === canonicalSvnUrl(b); }
19
+ catch { return false; }
7
20
  }
8
- export function sameSvnUrl(a,b){try{return canonicalSvnUrl(a)===canonicalSvnUrl(b);}catch{return false;}}
@@ -1,10 +1,24 @@
1
- import {hash} from './content.mjs';
2
- import {sameSvnUrl,canonicalSvnUrl} from './svn-url.mjs';
3
- export function svnWorkingIdentity(node,rootInfo,name){
4
- const expected=name==='.'?rootInfo.url:rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');
1
+ import { hash } from './content.mjs';
2
+ import { sameSvnUrl, canonicalSvnUrl } from './svn-url.mjs';
5
3
 
6
- if(!sameSvnUrl(node?.url,expected))throw Object.assign(new Error('SVN node URL differs from selected working-copy path; switched ancestors are unsupported'),{code:'SVN_SWITCHED_ANCESTOR'});
7
- if(!rootInfo.repository?.uuid||node.repository?.uuid!==rootInfo.repository.uuid||node.repository?.root!==rootInfo.repository.root||node['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw Object.assign(new Error('SVN node repository or working-copy identity mismatch'),{code:'SVN_NODE_IDENTITY'});
8
- const source={url:canonicalSvnUrl(node.url),repositoryUuid:node.repository.uuid,repositoryRoot:node.repository.root,revision:String(node['@_revision']),kind:node['@_kind'],...(node['@_kind']==='dir'?{depth:node['wc-info']?.depth??null}:{}),schedule:node['wc-info']?.schedule,copyFromUrl:node['wc-info']?.['copy-from-url']??null,copyFromRevision:node['wc-info']?.['copy-from-rev']??null};
9
- return {source,fingerprint:hash(JSON.stringify(source))};
4
+ export function svnWorkingIdentity(node, rootInfo, name) {
5
+ const expected = name === '.'
6
+ ? rootInfo.url
7
+ : rootInfo.url.replace(/\/$/, '') + '/' + name.split('/').map(encodeURIComponent).join('/');
8
+
9
+ if (!sameSvnUrl(node?.url, expected)) throw Object.assign(new Error('SVN node URL differs from selected working-copy path; switched ancestors are unsupported'), { code: 'SVN_SWITCHED_ANCESTOR' });
10
+ if (!rootInfo.repository?.uuid || node.repository?.uuid !== rootInfo.repository.uuid || node.repository?.root !== rootInfo.repository.root || node['wc-info']?.['wcroot-abspath'] !== rootInfo['wc-info']?.['wcroot-abspath']) throw Object.assign(new Error('SVN node repository or working-copy identity mismatch'), { code: 'SVN_NODE_IDENTITY' });
11
+
12
+ const source = {
13
+ url: canonicalSvnUrl(node.url),
14
+ repositoryUuid: node.repository.uuid,
15
+ repositoryRoot: node.repository.root,
16
+ revision: String(node['@_revision']),
17
+ kind: node['@_kind'],
18
+ ...(node['@_kind'] === 'dir' ? { depth: node['wc-info']?.depth ?? null } : {}),
19
+ schedule: node['wc-info']?.schedule,
20
+ copyFromUrl: node['wc-info']?.['copy-from-url'] ?? null,
21
+ copyFromRevision: node['wc-info']?.['copy-from-rev'] ?? null,
22
+ };
23
+ return { source, fingerprint: hash(JSON.stringify(source)) };
10
24
  }