@feng0531/dsh-code-review 0.36.0 → 0.38.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 +11 -1
- package/README.md +2 -2
- package/index.mjs +1 -1
- package/package.json +1 -1
- package/src/defect-guidance.mjs +5 -4
- package/src/protocol-guidance.mjs +24 -0
- package/src/snapshot.mjs +2 -2
- package/src/svn-caller-capture.mjs +48 -0
- package/src/svn-caller-index.mjs +10 -0
- package/src/svn-caller-node.mjs +11 -0
- package/src/svn-caller-safety.mjs +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
- protobuf/thrift/capnp/GraphQL/Prisma五类专用缺陷指导替换同泛化protocol提示,区分wire/JSON/生成API/迁移兼容方向,保留实际消费者/版本及反证前提;GraphQL不混wire/tag概念。
|
|
6
|
+
- 仅精确扩展名选取(大小写兼容),不匹配备份/伪后缀;原common与notice保持,指导不授读取或证据权限。主审/窗口/交互/复核指导继承与原输入预算验收,378全量测试通过,无新增调用;待固定标签原工件发布。
|
|
7
|
+
|
|
8
|
+
## 0.37.0
|
|
9
|
+
|
|
10
|
+
- callerScopePaths接入SVN离线限定目录normal引用方扫描;正文及负匹配hash/node/index/probe复核,共享原30秒、128正文/1MiB、复核2MiB、20上下文/4MiB快照,字面导航不证明语义调用覆盖。
|
|
11
|
+
- 祖先与节点真实路径/URL链/wcroot/UUID及属性校验,拒绝switched/external/special/keywords;竞争含本地未跟踪路径但无正文权限。真实SVN预览零发送、过期拒绝、满槽、二进制及超限验收,375项离线通过;固定标签CI37933398779四矩阵通过;唯一npm发布37933848198成功但tarball传播404使核验超时,同原artifact11617224527恢复37935128694成功;99052字节npm/Release逐字节及双hash/清单匹配正式交付,无重新pack或npm publish。
|
|
12
|
+
|
|
3
13
|
## 0.36.0
|
|
4
14
|
|
|
5
15
|
- callerScopePaths支持Git固定commit/range两侧对象内有界字面引用发现;读取匹配/非匹配blob并绑定OID/hash/version与索引/竞争探针,execute重捕获;不checkout/fetch/读取工作树,SVN仍不支持。
|
|
6
|
-
- 两侧共用30秒、128正文/1MiB、256记录/64KiB及512探针/1MiB;共享20上下文/4MiB快照,oldOnly复用不自动批准new侧;根提交、删除、竞争歧义、共享扫描耗尽及预览执行已验收。导航不证明调用/语义绑定或完整覆盖,真实模型质量未验收。
|
|
16
|
+
- 两侧共用30秒、128正文/1MiB、256记录/64KiB及512探针/1MiB;共享20上下文/4MiB快照,oldOnly复用不自动批准new侧;根提交、删除、竞争歧义、共享扫描耗尽及预览执行已验收。导航不证明调用/语义绑定或完整覆盖,真实模型质量未验收。370冻结测试及四矩阵通过;唯一发布37929860863原artifact11615712667的95603字节与npm/Release逐字节、双hash及清单一致。
|
|
7
17
|
|
|
8
18
|
## 0.35.0
|
|
9
19
|
|
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## 当前状态
|
|
6
6
|
|
|
7
|
-
当前正式版本为 0.
|
|
7
|
+
当前正式版本为 0.37.0(固定标签四矩阵及npm/Release原工件字节双hash门禁通过)。JSON围栏兼容与有界窗口上下文已交付;实际项目审查效果仍需原报告和新预览执行验证。
|
|
8
8
|
|
|
9
9
|
Git历史预览新增oldContextPaths:最多20个显式仓库相对文件,与contextPaths/自动上下文共享20槽、256KiB每blob及4MiB快照。仅从baseline普通Git blob捕获,不checkout/fetch/读取工作树;根提交无baseline拒绝,working/SVN明确拒绝。旧侧条目携带oldRevision/oldBlobOid/oldHash,仅进入context-old;oldOnly表示仅批准旧侧,不证明target路径不存在。所选变更复用原changed-old,不重复上下文;excluded/blocked变更、链接/submodule、凭据路径不能重新进入。预览显示身份元数据、不显示正文,确认执行才按宿主正常权限发送。主审/综合/复核按侧提示,计入原输入预算,旧证据不能冒充新侧回归证明。
|
|
10
10
|
|
|
11
|
-
预览可选callerScopePaths,最多4个非根、非重叠相对目录,支持Git
|
|
11
|
+
预览可选callerScopePaths,最多4个非根、非重叠相对目录,支持Git/SVN工作区及Git固定commit/range两侧对象。SVN仅离线normal普通节点,校验祖先与文件URL链/wcroot/UUID及属性,拒绝switched/external/special/keywords;竞争路径含本地未跟踪项但不授其正文。历史两侧共用30秒、128正文/1MiB、256记录/64KiB、512竞争探针/1MiB,blob版本/OID/hash及非匹配身份绑定;不读取工作树。预览读取目录内tracked JS/TS/C-family候选正文(含非匹配),仅匹配引用方作为上下文发送模型;128扫描文件/1MiB正文/256索引记录/64KiB索引/512竞争路径探针,发现与重核共享30秒,复核累计2MiB,仍共享20上下文及4MiB快照。绑定所有扫描hash、索引及竞争探针;execute重新捕获。SVN目录深度<=32、索引256条/64KiB、探针512条/1MiB;与Git工作区同共享30秒/正文128条1MiB/复核2MiB,缺正文/超限fail-closed;历史root无旧侧,删除仅旧侧导航,oldOnly上下文复用不新增new授权。字面引用仅导航,不证明函数调用或完整覆盖,报告保留partial限制。
|
|
12
12
|
|
|
13
13
|
最终报告记录业务需求hash/bytes用于溯源,不重复需求正文;真实Git当前/历史捕获及服务预览执行预算集成已验收。
|
|
14
14
|
|
package/index.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export function apply(ctx, config = {}) {
|
|
|
12
12
|
};
|
|
13
13
|
ctx.tools.register({
|
|
14
14
|
name: 'code_review_preview', description: 'Preview Git/SVN working-copy or Git commit/range review scope and model destination without sending source to a model. repositoryPath selects an explicit child repository relative to session workspace; file paths are repository-relative. Preview each repository separately. Show the preview before execution.',
|
|
15
|
-
parameters: { type: 'object', additionalProperties: false, properties: { reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git historical baseline-only blob context paths. Shares 20 context slots. No target text is inferred; selected old changes reuse their original identity, excluded changes cannot reenter. Unsupported on working reviews and SVN.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git working or fixed commit/range review
|
|
15
|
+
parameters: { type: 'object', additionalProperties: false, properties: { reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git historical baseline-only blob context paths. Shares 20 context slots. No target text is inferred; selected old changes reuse their original identity, excluded changes cannot reenter. Unsupported on working reviews and SVN.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git/SVN working review or Git fixed commit/range review. SVN uses offline normal nodes, rejects unsafe ancestor states and special/keyword properties. Historical sides share scan budgets and use object blobs only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Explicit context uses target tree with available baseline provenance; oldContextPaths is baseline-only. Historical caller discovery scans fixed sides; explicit rules use target-tree blobs.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
|
|
16
16
|
output,
|
|
17
17
|
async execute(args, exec) { const preview = await service.preview(args, exec); return { json: JSON.stringify(preview), markdown: '' }; },
|
|
18
18
|
});
|
package/package.json
CHANGED
package/src/defect-guidance.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {protocolGuidance} from './protocol-guidance.mjs';
|
|
1
2
|
// Original defect checklists, not executable rules or proof of a defect.
|
|
2
3
|
const common = ['Only report a concrete defect with trigger, impact and changed-code evidence.', 'Check before/after behavior and counterexamples; absent context means uncertainty, not an invented contract.'];
|
|
3
4
|
const checklists = {
|
|
@@ -17,14 +18,14 @@ const checklists = {
|
|
|
17
18
|
configuration: ['Check changed configuration keys against consumer/schema evidence, type/unit/default mismatches and compatibility.', 'Check exposed secrets, privilege expansion and unsafe interpolation only with concrete supported evidence.']
|
|
18
19
|
};
|
|
19
20
|
export function defectGuidance(filePath, sourceText = '') {
|
|
20
|
-
const lower = filePath.toLowerCase();
|
|
21
|
+
const lower = filePath.toLowerCase(), protocol = protocolGuidance(filePath);
|
|
21
22
|
const first = typeof sourceText === 'string' ? sourceText.slice(0,8192).split('\n').map(s=>s.trim()).find(Boolean) ?? '' : '';
|
|
22
23
|
const sniffed = lower.endsWith('.m') ? (/^(?:#(?:import|include|pragma|if|define)\b|@(?:import|interface|implementation|class|protocol)\b|\/\/|\/\*)/.test(first) ? 'objc' : 'matlab') : null;
|
|
23
24
|
const specialized = /\.(shader|hlsl|glsl|cginc|hlsli|compute)$/.test(lower) ? 'shader' : sniffed ?? (/\.mm$/.test(lower) ? 'objc' : /\.swift$/.test(lower) ? 'swift' : null) ?? (/\.java$/.test(lower) ? 'java' : /\.go$/.test(lower) ? 'go' : /\.(py|pyi)$/.test(lower) ? 'python' : /\.rs$/.test(lower) ? 'rust' : /\.(kt|kts)$/.test(lower) ? 'kotlin' : /\.(php|phtml)$/.test(lower) ? 'php' : null);
|
|
24
|
-
const language = specialized ?? (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/.test(lower) ? 'cpp' : lower.endsWith('.cs') ? 'csharp' : /\.(js|jsx|mjs|cjs|ts|tsx)$/.test(lower) ? 'javascript' : /\.(json|yaml|yml|toml|ini|xml|properties)$/.test(lower) ? 'configuration' : 'generic');
|
|
25
|
+
const language = protocol?.language ?? specialized ?? (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/.test(lower) ? 'cpp' : lower.endsWith('.cs') ? 'csharp' : /\.(js|jsx|mjs|cjs|ts|tsx)$/.test(lower) ? 'javascript' : /\.(json|yaml|yml|toml|ini|xml|properties)$/.test(lower) ? 'configuration' : 'generic');
|
|
25
26
|
const fileType = /^\.github\/workflows\/.+\.(yaml|yml)$/.test(lower) ? 'github-workflow' : /(?:^|\/)(package\.json|cargo\.toml|pom\.xml|build\.gradle|composer\.json)$/.test(lower) ? 'dependency-manifest' : undefined;
|
|
26
27
|
const purpose = fileType ?? (/\.(ftl|ftlh|ftlx|hbs|mustache|jinja2|j2|pug|astro)$/.test(lower) ? 'template' : /\.(tf|hcl|tfvars|bicep|nix)$/.test(lower) ? 'infrastructure' : /\.(proto|thrift|capnp|graphql|gql|prisma)$/.test(lower) ? 'protocol-schema' : undefined);
|
|
27
|
-
const purposeChecks = purpose === 'template' ? ['Check escaping for the actual HTML/attribute/URL/script context, untrusted raw output and template execution boundaries.', 'Check missing/null variables and changed conditional branches against captured callers; do not invent engine defaults.'] : purpose === 'infrastructure' ? ['Check privilege/network exposure, secret/state handling and untrusted interpolation against actual provider semantics.', 'Check replacement/deletion, dependency order and changed defaults for concrete data loss or service disruption; configuration is not an applied plan.'] : purpose === 'protocol-schema' ?
|
|
28
|
+
const purposeChecks = purpose === 'template' ? ['Check escaping for the actual HTML/attribute/URL/script context, untrusted raw output and template execution boundaries.', 'Check missing/null variables and changed conditional branches against captured callers; do not invent engine defaults.'] : purpose === 'infrastructure' ? ['Check privilege/network exposure, secret/state handling and untrusted interpolation against actual provider semantics.', 'Check replacement/deletion, dependency order and changed defaults for concrete data loss or service disruption; configuration is not an applied plan.'] : purpose === 'protocol-schema' ? protocol.checks : [];
|
|
28
29
|
const typeChecks = fileType === 'github-workflow' ? ['Check event trust boundaries, token permissions and fork/PR-controlled inputs before privileged commands or secret exposure.', 'Check expression interpolation into shell commands, checkout/ref provenance, artifact/cache trust and required job dependency gates.'] : fileType === 'dependency-manifest' ? ['Check changed dependency versions/features against consumers, lock/resolution evidence and runtime compatibility; do not invent a vulnerability from a package name.', 'Check executable install/build hooks, publish/export entry points and platform constraints for concrete changed behavior.'] : [];
|
|
29
|
-
return { version:
|
|
30
|
+
return { version:5, language, ...(sniffed ? {selection:'bounded-first-nonblank-line-hint'} : {}), ...(purpose ? {fileType:purpose} : {}), checks:[...common,...(checklists[language] ?? []),...typeChecks,...purposeChecks], notice:'Guidance is not evidence, a semantic parser, a style rule or permission to read outside the snapshot.' };
|
|
30
31
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Original, bounded review hints; no parser, I/O, schema enforcement or defect proof.
|
|
2
|
+
const checks={
|
|
3
|
+
protobuf:[
|
|
4
|
+
'Check numeric tag/enum value reuse and removed field reservations against persisted messages and actual consumers; additive fields alone are not defects.',
|
|
5
|
+
'Check oneof, presence, defaults and type changes for the deployed syntax/edition/runtime; distinguish binary wire compatibility from JSON names and generated API compatibility.'
|
|
6
|
+
],
|
|
7
|
+
thrift:[
|
|
8
|
+
'Check field IDs/types, requiredness and defaults against the actual protocol and old/new consumers; declaration order is not identity and Thrift has no reserved keyword.',
|
|
9
|
+
'Check method names, oneway/reply semantics, exceptions and include/namespace bindings against generated clients; additive optional fields alone do not prove breakage.'
|
|
10
|
+
],
|
|
11
|
+
capnp:[
|
|
12
|
+
'Check ordinals, explicit type IDs, fixed-width storage and default XOR encoding against existing data and deployed readers; do not infer identity from declaration order.',
|
|
13
|
+
'Check union/layout evolution and generated API consumers separately from wire compatibility; same-ordinal rename alone usually does not break wire data.'
|
|
14
|
+
],
|
|
15
|
+
graphql:[
|
|
16
|
+
'Check input non-null/default changes, removed fields/arguments and operation compatibility using actual clients; GraphQL has no numeric field tags.',
|
|
17
|
+
'Check output null bubbling, enum consumers and resolver authorization using captured implementation; absent auth directives or additive fields/enum values alone do not prove a defect.'
|
|
18
|
+
],
|
|
19
|
+
prisma:[
|
|
20
|
+
'Check provider, relationMode, relation actions, constraints and nullability against the deployed database, migration and generated-client consumers; schema is not an applied migration.',
|
|
21
|
+
'Check concrete data loss, migration order and client rollout counterexamples; do not require indexes or tenant fields from names alone or invent database enforcement in emulated relations.'
|
|
22
|
+
]
|
|
23
|
+
};
|
|
24
|
+
export function protocolGuidance(filePath){const lower=filePath.toLowerCase();const language=/\.proto$/.test(lower)?'protobuf':/\.thrift$/.test(lower)?'thrift':/\.capnp$/.test(lower)?'capnp':/\.(graphql|gql)$/.test(lower)?'graphql':/\.prisma$/.test(lower)?'prisma':undefined;return language?{language,checks:[...checks[language]]}:undefined;}
|
package/src/snapshot.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {captureSvnCallers} from './svn-caller-capture.mjs';
|
|
1
2
|
import { captureCallers } from './caller-capture.mjs';
|
|
2
3
|
import { validateCallerScopes } from './caller-index.mjs';
|
|
3
4
|
import { bindBusinessRequirement, validateBusinessRequirement } from './business-requirement.mjs';
|
|
@@ -38,7 +39,6 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
38
39
|
for (const name of options.oldContextPaths ?? []) relativePath(name);
|
|
39
40
|
const type = await detectVcs(cwd);
|
|
40
41
|
if (options.oldContextPaths !== undefined && (type !== 'git' || !['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined))) throw new Error('Old context requires Git historical review');
|
|
41
|
-
if (options.callerScopePaths !== undefined && type !== 'git') throw new Error('Caller discovery currently requires Git review');
|
|
42
42
|
if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
|
|
43
43
|
if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
|
|
44
44
|
return bindBusinessRequirement(await captureGitHistory(cwd, options),options.businessRequirement);
|
|
@@ -72,7 +72,7 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
72
72
|
}
|
|
73
73
|
let callerDiscovery, verifyCallers;
|
|
74
74
|
if (options.callerScopePaths !== undefined) {
|
|
75
|
-
const callers = await captureCallers(snapshot.root, snapshot.files, options.callerScopePaths, options);
|
|
75
|
+
const callers = await (type==='git'?captureCallers:captureSvnCallers)(snapshot.root, snapshot.files, options.callerScopePaths, options);
|
|
76
76
|
verifyCallers = callers.verify;
|
|
77
77
|
callerDiscovery = { ...callers.discovery, capturedPaths: [] };
|
|
78
78
|
for (const item of callers.context) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {svnCallerIndex} from './svn-caller-index.mjs';
|
|
2
|
+
import {svnCallerNode,svnCallerProbes} from './svn-caller-node.mjs';
|
|
3
|
+
|
|
4
|
+
import {callerCandidates} from './caller-candidates.mjs';
|
|
5
|
+
import {readLocal} from './content.mjs';
|
|
6
|
+
import {isSecretPath} from './secret-path.mjs';
|
|
7
|
+
|
|
8
|
+
// Called only by preview capture, never by a model retrieval request.
|
|
9
|
+
export async function captureSvnCallers(root, files, scopes, options={}) {
|
|
10
|
+
const deadline=AbortSignal.timeout(30000);
|
|
11
|
+
const signal=options.signal?AbortSignal.any([options.signal,deadline]):deadline;
|
|
12
|
+
const bounded={...options,signal,maxFileBytes:Math.min(options.maxFileBytes??256*1024,256*1024)};
|
|
13
|
+
const index=await svnCallerIndex(root,scopes,bounded);
|
|
14
|
+
const changed=new Set(files.flatMap(f=>[f.path,...(f.oldPath?[f.oldPath]:[])]));
|
|
15
|
+
const rules=new Set(options.rulePaths??[]),scanned=[],skipped=[];
|
|
16
|
+
let scannedBytes=0;
|
|
17
|
+
for(const name of index.paths){
|
|
18
|
+
signal.throwIfAborted();
|
|
19
|
+
if(changed.has(name)||rules.has(name)||isSecretPath(name)) {skipped.push({path:name,reason:'Excluded from caller discovery'});continue;}
|
|
20
|
+
if(!/\.(mjs|cjs|js|jsx|ts|tsx|c|cc|cpp|cxx|h|hh|hpp|hxx|shader|hlsl|glsl|cginc|hlsli|compute)$/i.test(name)){skipped.push({path:name,reason:'Unsupported caller language'});continue;}
|
|
21
|
+
if(scanned.length>=128)throw new Error('Caller scan file limit exceeded');
|
|
22
|
+
// Read failures are fatal: unread bytes are not negative search evidence.
|
|
23
|
+
const identity=await svnCallerNode(root,name,bounded);
|
|
24
|
+
const content=await readLocal(root,name,bounded);
|
|
25
|
+
scannedBytes+=content.bytes;
|
|
26
|
+
if(scannedBytes>1024*1024)throw new Error('Caller scan byte limit exceeded');
|
|
27
|
+
scanned.push({path:name,...content,nodeFingerprint:identity.fingerprint,revision:identity.revision});
|
|
28
|
+
}
|
|
29
|
+
const requested=callerCandidates(files,scanned,[]).probePaths;
|
|
30
|
+
const probes=await svnCallerProbes(root,requested,bounded);
|
|
31
|
+
const plan=callerCandidates(files,scanned,probes.paths);
|
|
32
|
+
let verifiedBytes=0;
|
|
33
|
+
const verify=async()=>{
|
|
34
|
+
for(const source of scanned){
|
|
35
|
+
const identity=await svnCallerNode(root,source.path,bounded);
|
|
36
|
+
if(identity.fingerprint!==source.nodeFingerprint)throw new Error('SVN caller node changed during capture');
|
|
37
|
+
const again=await readLocal(root,source.path,bounded);verifiedBytes+=again.bytes;
|
|
38
|
+
if(verifiedBytes>2*1024*1024)throw new Error('Caller verification byte limit exceeded');
|
|
39
|
+
if(again.hash!==source.hash)throw new Error('Caller scan source changed during capture');
|
|
40
|
+
}
|
|
41
|
+
if((await svnCallerIndex(root,scopes,bounded)).fingerprint!==index.fingerprint)throw new Error('Caller index changed during capture');
|
|
42
|
+
if((await svnCallerProbes(root,requested,bounded)).fingerprint!==probes.fingerprint)throw new Error('Caller resolution changed during capture');
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
};
|
|
45
|
+
await verify();
|
|
46
|
+
const paths=new Set(plan.candidates.map(c=>c.fromPath));
|
|
47
|
+
return {verify,context:scanned.filter(s=>paths.has(s.path)),discovery:{scopes:index.scopes,indexFingerprint:index.fingerprint,probeFingerprint:probes.fingerprint,scanned:scanned.map(({path,hash,bytes,nodeFingerprint,revision})=>({path,hash,bytes,nodeFingerprint,revision})),scannedBytes,skipped,candidates:plan.candidates,notice:plan.notice,incomplete:true,limitations:['Only supported literal references inside approved scopes were searched; no complete caller or semantic coverage.']}};
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import {svnCallerAncestors} from './svn-caller-safety.mjs';
|
|
2
|
+
import {checked} from './process.mjs';
|
|
3
|
+
import {xml} from './svn.mjs';
|
|
4
|
+
import {relativePath,hash} from './content.mjs';
|
|
5
|
+
import {validateCallerScopes} from './caller-index.mjs';
|
|
6
|
+
import {lstat} from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const canonical=v=>Array.isArray(v)?v.map(canonical):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])):v;
|
|
9
|
+
// Offline scoped status only, never svn list or repository-wide enumeration.
|
|
10
|
+
export async function svnCallerIndex(root,scopes,options={}){const approved=validateCallerScopes(scopes),entries=[],chunks=[];let bytes=0;const seen=new Set();for(const scope of approved){const ancestors=await svnCallerAncestors(root,scope,options);chunks.push(ancestors.fingerprint);let cursor=root;for(const part of scope.split('/')){cursor=path.join(cursor,part);const s=await lstat(cursor);if(s.isSymbolicLink()||!s.isDirectory())throw new Error('SVN caller scope requires real directories');}const raw=await checked('svn',['--non-interactive','status','--xml','--verbose','--ignore-externals','--',scope+'@'],{...options,cwd:root,maxBytes:64*1024});bytes+=raw.length;if(bytes>64*1024)throw new Error('SVN caller index byte limit exceeded');const found=(xml(raw).status?.target??[]).flatMap(t=>t.entry??[]);for(const e of found){const name=relativePath(e['@_path'].replaceAll('\\','/'));if(name!==scope&&!name.startsWith(scope+'/'))throw new Error('SVN caller index escaped scope');if(seen.has(name))throw new Error('Duplicate SVN caller index path');seen.add(name);if(seen.size>256)throw new Error('SVN caller index path limit exceeded');entries.push({...e,'@_path':name});}if(!found.some(e=>e['@_path'].replaceAll('\\','/')===scope))throw new Error('SVN caller scope missing from status');chunks.push(scope);}entries.sort((a,b)=>a['@_path']<b['@_path']?-1:1);const unsafe=s=>!s||s['@_item']!=='normal'||s['@_props']!=='none'||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true');for(const scope of approved){const e=entries.find(e=>e['@_path']===scope);if(unsafe(e?.['wc-status']))throw new Error('Unsafe SVN caller directory state');}return {scopes:approved,paths:entries.filter(e=>!approved.includes(e['@_path'])&&!unsafe(e['wc-status'])).map(e=>e['@_path']),entries,records:entries.length,bytes,fingerprint:hash(JSON.stringify(canonical({scopes:chunks,entries})))};}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {checked} from './process.mjs';
|
|
2
|
+
import {xml} from './svn.mjs';
|
|
3
|
+
import {relativePath,hash} from './content.mjs';
|
|
4
|
+
import {lstat} from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {svnCallerAncestors} from './svn-caller-safety.mjs';
|
|
7
|
+
const canonical=v=>Array.isArray(v)?v.map(canonical):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])):v;
|
|
8
|
+
// Body authorization requires regular local node and offline SVN identity/properties.
|
|
9
|
+
export async function svnCallerNode(root,name,options={}){relativePath(name);const parent=path.posix.dirname(name);if(parent!=='.')await svnCallerAncestors(root,parent,options);const stat=await lstat(path.join(root,name));if(stat.isSymbolicLink()||!stat.isFile())throw new Error('SVN caller source is not a regular file');const run=async args=>xml(await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:root,maxBytes:64*1024}));const entries=(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty'])).status?.target?.flatMap(t=>t.entry??[])??[];if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller source status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller source state');const info=(await run(['info','--xml'])).info?.entry?.[0];const rootInfo=xml(await checked('svn',['--non-interactive','info','--xml','--','.@'],{...options,cwd:root,maxBytes:64*1024})).info?.entry?.[0];if(info?.['@_kind']!=='file'||info['wc-info']?.['copy-from-url']||info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('Unsafe SVN caller source identity');const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched source URL');const props=(await run(['proplist','--xml','--verbose'])).properties?.target?.[0]?.property??[];if(props.some(p=>['svn:special','svn:keywords','svn:externals'].includes(p['@_name'])))throw new Error('Unsafe SVN caller source properties');return {revision:s['@_revision'],fingerprint:hash(JSON.stringify(canonical({status:entries[0],info,props})))};}
|
|
10
|
+
// Existing untracked/linked/directory paths compete too; absence grants no body read.
|
|
11
|
+
export async function svnCallerProbes(root,names,options={}){if(!Array.isArray(names)||names.length>512||new Set(names).size!==names.length)throw new Error('Invalid SVN caller probes');const existing=[],identities=[];for(const name of [...names].sort()){relativePath(name);if(name.split('/').length>32)throw new Error('SVN caller probe depth limit exceeded');options.signal?.throwIfAborted();let cursor=root,identity=[];for(const part of name.split('/')){cursor=path.join(cursor,part);try{const s=await lstat(cursor);identity.push({part,kind:s.isSymbolicLink()?'link':s.isDirectory()?'dir':s.isFile()?'file':'other'});if(s.isSymbolicLink()){existing.push(name);break;}if(cursor===path.join(root,name))existing.push(name);if(!s.isDirectory())break;}catch(e){if(e.code!=='ENOENT'&&e.code!=='ENOTDIR')throw e;identity.push({part,kind:'missing'});break;}}identities.push({name,identity});if(Buffer.byteLength(JSON.stringify(identities))>1024*1024)throw new Error('SVN caller probe metadata limit exceeded');}return {paths:existing,fingerprint:hash(JSON.stringify(identities))};}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {checked} from './process.mjs';
|
|
2
|
+
import {xml} from './svn.mjs';
|
|
3
|
+
import {relativePath,hash} from './content.mjs';
|
|
4
|
+
import {lstat,realpath} from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const canonicalValue=v=>Array.isArray(v)?v.map(canonicalValue):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonicalValue(v[k])])):v;
|
|
7
|
+
// Offline metadata on each explicit ancestor. Never follows external/switched roots.
|
|
8
|
+
export async function svnCallerAncestors(root,scope,options={}){relativePath(scope);if(scope.split('/').length>32)throw new Error('SVN caller ancestor depth limit exceeded');const canonical=await realpath(root),names=['.',...scope.split('/').map((_,i)=>scope.split('/').slice(0,i+1).join('/'))],identities=[];let bytes=0,rootInfo;for(const name of names){options.signal?.throwIfAborted();const local=name==='.'?canonical:path.join(canonical,name);const stat=await lstat(local);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('SVN caller scope requires real directories');const run=async args=>{const b=await checked('svn',['--non-interactive',...args,'--',name+'@'],{...options,cwd:canonical,maxBytes:64*1024});bytes+=b.length;if(bytes>256*1024)throw new Error('SVN caller ancestor metadata limit exceeded');return xml(b);};const status=await run(['status','--xml','--verbose','--ignore-externals','--depth','empty']);const entries=(status.status?.target??[]).flatMap(t=>t.entry??[]);if(entries.length!==1||entries[0]['@_path'].replaceAll('\\','/')!==name)throw new Error('SVN caller ancestor status missing');const s=entries[0]['wc-status'];if(s?.['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe SVN caller ancestor state');const info=(await run(['info','--xml'])).info?.entry?.[0];if(!info||info['@_kind']!=='dir'||info['wc-info']?.['copy-from-url'])throw new Error('Unsafe SVN caller ancestor identity');rootInfo??=info;if(info.repository?.uuid!==rootInfo.repository?.uuid||info['wc-info']?.['wcroot-abspath']!==rootInfo['wc-info']?.['wcroot-abspath'])throw new Error('SVN caller ancestor external working root');if(name!=='.'){const expected=rootInfo.url.replace(/\/$/,'')+'/'+name.split('/').map(encodeURIComponent).join('/');if(info.url!==expected)throw new Error('Unsafe SVN caller switched ancestor URL');}const properties=await run(['proplist','--xml','--verbose']);const props=properties.properties?.target?.[0]?.property??[];if(props.some(p=>['svn:externals','svn:special'].includes(p['@_name'])))throw new Error('Unsafe SVN caller ancestor properties');identities.push({path:name,status:entries[0],info,properties:props});}return {bytes,fingerprint:hash(JSON.stringify(canonicalValue(identities)))};}
|