@entrorove/skill 0.1.23 → 0.1.25
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/AGREEMENT.md +6 -6
- package/CAPABILITY-MAP.md +6 -0
- package/CHANGELOG.md +10 -0
- package/SKILL.md +18 -4
- package/archguard-runtime-build.mjs +15 -0
- package/blueprint-criterion-runner.mjs +110 -0
- package/blueprint-criterion-wire.mjs +78 -0
- package/calctool-runtime-build.mjs +59 -0
- package/calctool-runtime-entry.mjs +92 -0
- package/cli.mjs +35 -83
- package/local-approved-plan.mjs +74 -15
- package/local-archguard-ast.mjs +371 -0
- package/local-archguard-checkpoint.mjs +123 -0
- package/local-archguard-components.mjs +177 -0
- package/local-archguard-contract.mjs +109 -0
- package/local-archguard-contracts.mjs +42 -0
- package/local-archguard-evaluate.mjs +120 -0
- package/local-archguard-language-ast.mjs +165 -0
- package/local-archguard-mutation-fence.mjs +28 -0
- package/local-archguard-operations.mjs +25 -0
- package/local-archguard-scope.mjs +64 -0
- package/local-archguard-signing.mjs +52 -0
- package/local-archguard-snapshot.mjs +126 -0
- package/local-archguard-state.mjs +200 -0
- package/local-archguard.mjs +74 -0
- package/local-budget-git-attributes.mjs +49 -0
- package/local-budget-git-loader.mjs +61 -0
- package/local-budget-git-native-attributes.mjs +81 -0
- package/local-budget-git-native-darwin.mjs +104 -0
- package/local-budget-git-native-linux.mjs +93 -0
- package/local-budget-git-native-reads.mjs +88 -0
- package/local-budget-git-native-request.mjs +53 -0
- package/local-budget-git-native-runtime.mjs +18 -0
- package/local-budget-git-native.c +313 -0
- package/local-budget-git-native.mjs +109 -0
- package/local-budget-git-outcome.mjs +33 -0
- package/local-budget-git-scratch-loader.mjs +158 -0
- package/local-budget-git-scratch.mjs +112 -0
- package/local-budget-git-system-attributes.mjs +44 -0
- package/local-budget-git.mjs +92 -0
- package/local-budget-grants.mjs +82 -0
- package/local-budget-handoff-completion.mjs +70 -0
- package/local-budget-handoff-contracts.mjs +53 -0
- package/local-budget-handoff.mjs +229 -0
- package/local-budget-lock.mjs +55 -0
- package/local-budget-reads.mjs +135 -0
- package/local-budget-renewal.mjs +86 -34
- package/local-budget-source-bindings.mjs +51 -0
- package/local-budget-store.mjs +131 -0
- package/local-budget-transition.mjs +46 -0
- package/local-budget.mjs +185 -0
- package/local-calctool-cli.mjs +24 -0
- package/local-calctool-contracts.mjs +42 -0
- package/local-calctool-runtime.mjs +10 -0
- package/local-calctool-storage.mjs +101 -0
- package/local-calctool.mjs +103 -0
- package/local-chain-bindings.mjs +93 -0
- package/local-chain-broker.mjs +61 -0
- package/local-chain-contracts.mjs +21 -0
- package/local-chain-controller.mjs +321 -0
- package/local-chain-human.mjs +48 -0
- package/local-chain-lock.mjs +54 -0
- package/local-chain-operations.mjs +7 -0
- package/local-chain-plan.mjs +232 -0
- package/local-chain-release-labels.mjs +12 -0
- package/local-chain-results.mjs +75 -0
- package/local-cli.mjs +65 -24
- package/local-contract-types.mjs +46 -0
- package/local-contracts.mjs +126 -0
- package/local-credential.mjs +101 -0
- package/local-decimal.mjs +85 -0
- package/local-error.mjs +23 -0
- package/local-evidence-verification.mjs +199 -0
- package/local-execution.mjs +115 -304
- package/local-final-gate16-bundle.mjs +21 -0
- package/local-final-gate16-checks.mjs +88 -0
- package/local-final-gate16-contract.mjs +15 -0
- package/local-final-gate16-plan.mjs +66 -0
- package/local-final-gate16-proof.mjs +75 -0
- package/local-final-gate16-release.mjs +17 -0
- package/local-final-gate16-state.mjs +70 -0
- package/local-final-gate16-worker.mjs +19 -0
- package/local-final-gate16.mjs +137 -0
- package/local-formula-ast.mjs +106 -0
- package/local-formula-engine.mjs +125 -0
- package/local-formula-tests.mjs +61 -0
- package/local-formula.mjs +25 -125
- package/local-integrity.mjs +22 -41
- package/local-invocations.mjs +142 -0
- package/local-ledger.mjs +15 -9
- package/local-merge-archguard-verification.d.mts +44 -0
- package/local-merge-archguard-verification.mjs +240 -0
- package/local-merge-archguard.mjs +138 -0
- package/local-merge-artifacts.mjs +86 -0
- package/local-merge-authority.mjs +89 -0
- package/local-merge-contracts.mjs +56 -0
- package/local-merge-controller.mjs +170 -0
- package/local-merge-head.mjs +56 -0
- package/local-merge-journal.mjs +77 -0
- package/local-merge-mutation.mjs +97 -0
- package/local-merge-rollback.mjs +126 -0
- package/local-merge-snapshot-rollback.mjs +26 -0
- package/local-merge-storage.mjs +103 -0
- package/local-merge-targets.mjs +47 -0
- package/local-merge-transaction.mjs +133 -0
- package/local-mutation-audit.mjs +34 -0
- package/local-mutation-authority.mjs +103 -0
- package/local-mutation-batch-receipt.mjs +84 -0
- package/local-mutation-batch.mjs +143 -0
- package/local-mutation-binding.mjs +89 -0
- package/local-mutation-files.mjs +270 -0
- package/local-mutation-pass-store.mjs +63 -0
- package/local-mutation-snapshot.mjs +200 -0
- package/local-mutation-wire.mjs +89 -0
- package/local-probe.mjs +133 -11
- package/local-scope-candidate.mjs +28 -0
- package/local-scope-context.mjs +28 -0
- package/local-scope-files.mjs +106 -0
- package/local-scope-git-attributes.mjs +200 -0
- package/local-scope-git-comparison.mjs +8 -0
- package/local-scope-git-history.mjs +72 -0
- package/local-scope-git-reads.mjs +66 -0
- package/local-scope-git.mjs +124 -0
- package/local-scope-repository.mjs +249 -0
- package/local-scope-signature.mjs +70 -0
- package/local-scope-snapshot.mjs +39 -0
- package/local-scope-state.mjs +204 -0
- package/local-scope-workspaces.mjs +83 -0
- package/local-swarm-authority.mjs +143 -0
- package/local-swarm-baseline.mjs +32 -0
- package/local-swarm-completion.mjs +102 -0
- package/local-swarm-contracts.mjs +47 -0
- package/local-swarm-mutation.mjs +142 -0
- package/local-swarm-priority.mjs +237 -0
- package/local-swarm-state.mjs +102 -0
- package/local-swarm-storage.mjs +135 -0
- package/local-swarm.mjs +179 -0
- package/local-task-handoff-completion-wire.mjs +71 -0
- package/local-task-handoff-contracts.mjs +66 -0
- package/local-task-handoff-fence.mjs +478 -0
- package/local-task-handoff-observation.mjs +41 -0
- package/local-task-handoff-overlay-wire.mjs +94 -0
- package/local-task-handoff-read-authority.mjs +236 -0
- package/local-task-handoff-storage.mjs +73 -0
- package/local-task-handoff-write-archive.mjs +80 -0
- package/local-task-handoff-write-constraints.mjs +66 -0
- package/local-task-handoff-write-receipts.mjs +99 -0
- package/local-task-handoff-write-wire.mjs +99 -0
- package/local-task-routing.mjs +202 -22
- package/manifest.json +1 -1
- package/package.json +164 -3
- package/release-state.json +164 -18
- package/release.mjs +242 -4
- package/releases.json +12 -0
- package/remote-client.mjs +127 -36
- package/remote-transport.mjs +80 -0
- package/review-client.mjs +38 -0
- package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +1910 -0
- package/runtime/git-native-darwin-v1.mjs +5 -0
- package/runtime/git-native-linux-v1.mjs +5 -0
- package/task-message-cli.mjs +47 -0
- package/task-message-entry.mjs +159 -0
- package/workflow-local-client.mjs +35 -0
- package/workflow-local-contracts.mjs +58 -0
- package/workflow-local-request.mjs +53 -0
- package/workflow-local-runner.mjs +350 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
5
|
+
|
|
6
|
+
export function scopeFail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
7
|
+
export function observedScopeRisk(files) {
|
|
8
|
+
return files.some(file => /(?:^|\/)(?:auth|authentication|authorization|sessions?|secrets?|credentials?|payments?|billing|migrations?|sql|deploy(?:ment)?)(?:\/|[.-]|$)/i.test(file.path)
|
|
9
|
+
|| /(?:^|\/)(?:\.env(?:\..*)?|[^/]*\.(?:pem|key))$/i.test(file.path)) ? 'high' : 'low';
|
|
10
|
+
}
|
|
11
|
+
export function observedGoalRisk(goal) {
|
|
12
|
+
return /生产数据|支付|用户隐私|财务|密码|密钥|线上环境|\b(?:production|payments?|billing|passwords?|secrets?|credentials?|financial|privacy)\b/i.test(goal) ? 'high' : 'low';
|
|
13
|
+
}
|
|
14
|
+
export function scopePath(value) {
|
|
15
|
+
if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value)
|
|
16
|
+
|| /^[A-Za-z]:/.test(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value)
|
|
17
|
+
|| value.split('/').some(part => !part || part === '.' || part === '..')) scopeFail('ENTROROVE_PATH_UNSAFE', 'scope path is unsafe');
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export async function scopeRoot(value) {
|
|
21
|
+
if (typeof value !== 'string' || !path.isAbsolute(value)) scopeFail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute');
|
|
22
|
+
const stat = await fs.lstat(value);
|
|
23
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) scopeFail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory');
|
|
24
|
+
return fs.realpath(value);
|
|
25
|
+
}
|
|
26
|
+
export async function scopeTarget(root, relative, allowMissing = false) {
|
|
27
|
+
let current = root;
|
|
28
|
+
const parts = scopePath(relative).split('/');
|
|
29
|
+
for (const [index, part] of parts.entries()) {
|
|
30
|
+
current = path.join(current, part);
|
|
31
|
+
let stat;
|
|
32
|
+
try { stat = await fs.lstat(current); } catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT' && allowMissing) return { target: path.join(root, relative), exists: false, stat: null };
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
if (stat.isSymbolicLink() || (index < parts.length - 1 && !stat.isDirectory())) scopeFail('ENTROROVE_FILE_INVALID', 'scope refuses symlink or non-directory ancestors');
|
|
37
|
+
if (index === parts.length - 1) return { target: current, exists: true, stat };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function safeScopeRead(root, relative, input) {
|
|
41
|
+
const target = await scopeTarget(root, relative);
|
|
42
|
+
if (!target.stat.isFile()) scopeFail('ENTROROVE_FILE_INVALID', 'scope source must be a regular file');
|
|
43
|
+
if (target.stat.size > 16_777_216) scopeFail('ENTROROVE_SCOPE_LIMIT', 'scope source exceeds the 16 MiB inspection bound');
|
|
44
|
+
const handle = await fs.open(target.target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
45
|
+
try {
|
|
46
|
+
const before = await handle.stat();
|
|
47
|
+
const content = input ? await readBudgetedProjectFile(input, target.target, { sourcePath: relative }) : await handle.readFile();
|
|
48
|
+
const after = await handle.stat();
|
|
49
|
+
const latest = await fs.lstat(target.target);
|
|
50
|
+
if (before.ino !== target.stat.ino || before.dev !== target.stat.dev || after.size !== before.size
|
|
51
|
+
|| after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || latest.ino !== after.ino
|
|
52
|
+
|| latest.dev !== after.dev || latest.isSymbolicLink()) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope source changed during inspection');
|
|
53
|
+
await scopeTarget(root, relative);
|
|
54
|
+
return { content, mode: after.mode & 0o777, sha256: sha256(content), bytes: content.length };
|
|
55
|
+
} finally { await handle.close(); }
|
|
56
|
+
}
|
|
57
|
+
export const scopeRead = (root, relative, input = { repositoryRoot: root }) => safeScopeRead(root, relative, input);
|
|
58
|
+
// Internal control roles only: signed manifest metadata and already validated private signer files.
|
|
59
|
+
export const scopeControlRead = (root, relative) => safeScopeRead(root, relative, null);
|
|
60
|
+
const excluded = new Set(['.git', 'node_modules', '.entrorove', '.aimlock', '.archguard']);
|
|
61
|
+
export async function scopeFiles(root, requested, knownFiles = []) {
|
|
62
|
+
if (!Array.isArray(requested) || !requested.length) scopeFail('ENTROROVE_SCOPE_EMPTY', 'a non-empty target scope is required');
|
|
63
|
+
const found = new Set(); const known = new Set(knownFiles.map(file => file.path)); let visited = 0;
|
|
64
|
+
async function visit(relative) {
|
|
65
|
+
if (++visited > 10_000) scopeFail('ENTROROVE_SCOPE_LIMIT', 'scope exceeds the bounded path inspection limit');
|
|
66
|
+
const item = await scopeTarget(root, relative, known.has(relative));
|
|
67
|
+
if (!item.exists) { found.add(relative); return; }
|
|
68
|
+
if (item.stat.isFile()) { found.add(relative); return; }
|
|
69
|
+
if (!item.stat.isDirectory()) scopeFail('ENTROROVE_FILE_INVALID', 'scope target must be a regular file or directory');
|
|
70
|
+
for (const name of (await fs.readdir(item.target)).sort()) if (!excluded.has(name)) await visit(`${relative}/${name}`);
|
|
71
|
+
}
|
|
72
|
+
for (const relative of known) if (requested.some(target => target === '.' || target === relative || relative.startsWith(`${target}/`))) found.add(relative);
|
|
73
|
+
for (const relative of requested) {
|
|
74
|
+
if (relative === '.') {
|
|
75
|
+
for (const name of (await fs.readdir(root)).sort()) if (!excluded.has(name)) await visit(name);
|
|
76
|
+
} else await visit(scopePath(relative));
|
|
77
|
+
}
|
|
78
|
+
if (!found.size) scopeFail('ENTROROVE_SCOPE_EMPTY', 'target scope contains no files');
|
|
79
|
+
return [...found].sort();
|
|
80
|
+
}
|
|
81
|
+
export function lineCount(content) {
|
|
82
|
+
if (!content.length) return 0;
|
|
83
|
+
return content.toString('utf8').split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content.toString('utf8')) ? 1 : 0);
|
|
84
|
+
}
|
|
85
|
+
export async function packageBoundary(root, relative, input = { repositoryRoot: root }) {
|
|
86
|
+
let directory = path.posix.dirname(relative);
|
|
87
|
+
while (true) {
|
|
88
|
+
const manifestPath = directory === '.' ? 'package.json' : `${directory}/package.json`;
|
|
89
|
+
try {
|
|
90
|
+
const read = await scopeRead(root, manifestPath, input); let manifest;
|
|
91
|
+
try { manifest = JSON.parse(read.content.toString('utf8')); } catch { scopeFail('ENTROROVE_PACKAGE_INVALID', 'package manifest is not valid JSON'); }
|
|
92
|
+
return { path: directory, manifestPath, manifestDigest: read.sha256, name: typeof manifest.name === 'string' ? manifest.name : null };
|
|
93
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
94
|
+
if (directory === '.') break;
|
|
95
|
+
directory = path.posix.dirname(directory);
|
|
96
|
+
}
|
|
97
|
+
return { path: relative.includes('/') ? relative.split('/')[0] : '.', manifestPath: null, manifestDigest: null, name: null };
|
|
98
|
+
}
|
|
99
|
+
export async function stableScope(root, files, input = { repositoryRoot: root }) {
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
if (file.exists === false) {
|
|
102
|
+
if ((await scopeTarget(root, file.path, true)).exists) scopeFail('ENTROROVE_SCOPE_CHANGED', 'a deleted target reappeared during inspection');
|
|
103
|
+
} else if ((await scopeRead(root, file.path, input)).sha256 !== file.sha256) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope files changed before the observation completed');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export const scopeDigest = value => sha256(canonical(value));
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { sealBudgetedGitAttributes } from './local-budget-git-attributes.mjs';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { sha256 } from './local-integrity.mjs';
|
|
6
|
+
import { scopeFail, scopePath, scopeRead, scopeRoot, scopeTarget } from './local-scope-files.mjs';
|
|
7
|
+
|
|
8
|
+
const objectId = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
9
|
+
const unsupported = message => scopeFail('ENTROROVE_GIT_ATTRIBUTES_UNSUPPORTED', message);
|
|
10
|
+
const trimGit = value => value.replace(/^[ \t\r]+|[ \t\r]+$/g, '');
|
|
11
|
+
|
|
12
|
+
async function inspect(metadata, args, allowed = []) {
|
|
13
|
+
const value = await metadata(args, allowed);
|
|
14
|
+
if (value !== null && typeof value !== 'string') scopeFail('ENTROROVE_GIT_FAILED', 'Git attribute metadata is invalid');
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function optionalStat(target) {
|
|
19
|
+
try { return await fs.lstat(target); }
|
|
20
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function liveAttribute(root, relative, input) {
|
|
24
|
+
const target = await scopeTarget(root, relative, true);
|
|
25
|
+
if (!target.exists) return null;
|
|
26
|
+
if (!target.stat.isFile()) unsupported('Git attribute source must be a regular file');
|
|
27
|
+
const result = await scopeRead(root, relative, input);
|
|
28
|
+
return { content: result.content, evidence: { path: relative, sha256: result.sha256 } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function indexAttribute(relative, metadata, readIndexAttribute) {
|
|
32
|
+
const output = await inspect(metadata, ['ls-files', '--stage', '-z', '--', `:(literal)${relative}`]);
|
|
33
|
+
if (output === null || (output && !output.endsWith('\0'))) scopeFail('ENTROROVE_GIT_FAILED', 'Git attribute index metadata is unavailable or truncated');
|
|
34
|
+
let selected;
|
|
35
|
+
for (const record of output.split('\0').filter(Boolean)) {
|
|
36
|
+
const separator = record.indexOf('\t');
|
|
37
|
+
const match = /^([0-7]{6}) ([0-9a-f]+) ([0-3])$/.exec(record.slice(0, separator));
|
|
38
|
+
if (separator < 0 || !match || !objectId.test(match[2]) || record.slice(separator + 1) !== relative) scopeFail('ENTROROVE_GIT_FAILED', 'Git attribute index metadata is invalid');
|
|
39
|
+
if (match[3] !== '0') unsupported('Conflicted Git attribute index entries require a controlled stage-two source adapter');
|
|
40
|
+
if (selected) scopeFail('ENTROROVE_GIT_FAILED', 'Git attribute index metadata repeats stage zero');
|
|
41
|
+
if (!['100644', '100755'].includes(match[1])) unsupported('Git attribute index source must be a regular blob');
|
|
42
|
+
selected = { path: relative, mode: match[1], objectId: match[2], commitId: null, sourceKind: 'index' };
|
|
43
|
+
}
|
|
44
|
+
if (!selected) return null;
|
|
45
|
+
if (typeof readIndexAttribute !== 'function') scopeFail('ENTROROVE_BUDGET_SCOPE', 'Git attribute index source requires its original-path controlled reader');
|
|
46
|
+
const result = await readIndexAttribute(relative, selected);
|
|
47
|
+
if (!result || !Buffer.isBuffer(result.content) || result.sha256 !== sha256(result.content)) scopeFail('ENTROROVE_GIT_FAILED', 'Git attribute index source returned invalid content evidence');
|
|
48
|
+
if (result.content.length > 16_777_216) scopeFail('ENTROROVE_SCOPE_LIMIT', 'Git attribute index source exceeds the 16 MiB inspection bound');
|
|
49
|
+
const digest = createHash(selected.objectId.length === 40 ? 'sha1' : 'sha256').update(`blob ${result.content.length}\0`).update(result.content).digest('hex');
|
|
50
|
+
if (digest !== selected.objectId) scopeFail('ENTROROVE_GIT_FAILED', 'Git attribute index source does not match its actual object ID');
|
|
51
|
+
return { content: result.content, evidence: { path: relative, sha256: result.sha256, sourceKind: 'index', mode: selected.mode, objectId: selected.objectId } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function containedAttribute(root, candidate, input) {
|
|
55
|
+
const stat = await optionalStat(candidate);
|
|
56
|
+
if (!stat) return null;
|
|
57
|
+
const relative = path.relative(root, candidate);
|
|
58
|
+
if (!relative || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative) || stat.isSymbolicLink()) scopeFail('ENTROROVE_BUDGET_SCOPE', 'Git attribute source must be an actual root-contained file covered by the read grant');
|
|
59
|
+
return liveAttribute(root, scopePath(relative), input);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function configuredAttribute(root, input, metadata) {
|
|
63
|
+
const configured = await inspect(metadata, ['config', '--null', '--path', '--get', 'core.attributesFile'], [1]);
|
|
64
|
+
if (configured !== null && (!configured.endsWith('\0') || configured.slice(0, -1).includes('\0'))) scopeFail('ENTROROVE_GIT_FAILED', 'Git attributesFile metadata is invalid');
|
|
65
|
+
const value = configured === null ? null : configured.slice(0, -1);
|
|
66
|
+
if (value === '' || (value === null && !process.env.XDG_CONFIG_HOME && !process.env.HOME)) return null;
|
|
67
|
+
const defaultPath = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'git', 'attributes');
|
|
68
|
+
return containedAttribute(root, path.resolve(root, value ?? defaultPath), input);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function systemAttribute(root, input, metadata) {
|
|
72
|
+
const output = await inspect(metadata, ['var', 'GIT_ATTR_SYSTEM'], [1]);
|
|
73
|
+
if (output === null) return null;
|
|
74
|
+
if (!output.endsWith('\n') || !output.slice(0, -1) || output.slice(0, -1).includes('\n')) scopeFail('ENTROROVE_GIT_FAILED', 'Git system attribute metadata is invalid');
|
|
75
|
+
return containedAttribute(root, path.resolve(root, output.slice(0, -1)), input);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function quotedPattern(line) {
|
|
79
|
+
if (!line.startsWith('"')) {
|
|
80
|
+
const end = line.search(/[ \t\r]/);
|
|
81
|
+
return { pattern: end < 0 ? line : line.slice(0, end), rest: end < 0 ? '' : trimGit(line.slice(end)) };
|
|
82
|
+
}
|
|
83
|
+
const bytes = [];
|
|
84
|
+
const escaped = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '\\': 92, '"': 34 };
|
|
85
|
+
for (let index = 1; index < line.length; index++) {
|
|
86
|
+
const character = line[index];
|
|
87
|
+
if (character === '"') {
|
|
88
|
+
if (line[index + 1] && !/[ \t\r]/.test(line[index + 1])) unsupported('Quoted Git attribute pattern has trailing non-whitespace');
|
|
89
|
+
let pattern;
|
|
90
|
+
try { pattern = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.from(bytes)); }
|
|
91
|
+
catch { unsupported('Git attribute pattern must contain valid UTF-8'); }
|
|
92
|
+
return { pattern, rest: trimGit(line.slice(index + 1)) };
|
|
93
|
+
}
|
|
94
|
+
if (character !== '\\') {
|
|
95
|
+
const code = line.codePointAt(index);
|
|
96
|
+
bytes.push(...Buffer.from(String.fromCodePoint(code)));
|
|
97
|
+
if (code > 0xffff) index++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const next = line[++index];
|
|
101
|
+
if (Object.hasOwn(escaped, next)) bytes.push(escaped[next]);
|
|
102
|
+
else if (/[0-3]/.test(next ?? '') && /^[0-7]{2}$/.test(line.slice(index + 1, index + 3))) {
|
|
103
|
+
bytes.push(parseInt(line.slice(index, index + 3), 8)); index += 2;
|
|
104
|
+
} else unsupported('Git attribute pattern contains an unsupported C escape');
|
|
105
|
+
}
|
|
106
|
+
unsupported('Git attribute pattern has an unterminated quote');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function gitQuote(value) {
|
|
110
|
+
const escaped = { '\\': '\\\\', '"': '\\"', '\n': '\\n', '\r': '\\r', '\t': '\\t', '\b': '\\b', '\f': '\\f' };
|
|
111
|
+
return `"${value.replace(/[\\"\u0000-\u001f\u007f]/g, character => escaped[character] ?? `\\${character.charCodeAt(0).toString(8).padStart(3, '0')}`)}"`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function prefixPattern(pattern, directory, side) {
|
|
115
|
+
const base = directory ? `${directory.replace(/[\\*?\[\]]/g, value => `\\${value}`)}/` : '';
|
|
116
|
+
return `${side}/${base}${pattern.includes('/') ? pattern.replace(/^\//, '') : `**/${pattern}`}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function checkedAttributeLine(value) {
|
|
120
|
+
if (Buffer.byteLength(value, 'utf8') >= 2048) unsupported('Git attribute source or derived line reaches its 2048-byte parser bound');
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function deriveAttribute(content, directory, drivers, sourceKind) {
|
|
125
|
+
if (content.includes(0)) unsupported('Git attribute sources containing NUL require a separate source-kind parser');
|
|
126
|
+
let text;
|
|
127
|
+
try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(content); }
|
|
128
|
+
catch { unsupported('Git attribute source must contain valid UTF-8'); }
|
|
129
|
+
if (sourceKind !== 'index') text = text.replace(/^\uFEFF/, '');
|
|
130
|
+
const lines = [];
|
|
131
|
+
for (const original of text.split('\n')) {
|
|
132
|
+
checkedAttributeLine(original);
|
|
133
|
+
const line = trimGit(original);
|
|
134
|
+
if (!line || line.startsWith('#')) continue;
|
|
135
|
+
const { pattern, rest } = quotedPattern(line);
|
|
136
|
+
if (!pattern || pattern.includes('\0') || pattern.startsWith('!')) unsupported('Git attribute source contains an unsupported pattern');
|
|
137
|
+
const macro = pattern.length > '[attr]'.length && pattern.startsWith('[attr]');
|
|
138
|
+
if (macro && directory) unsupported('Nested Git attribute macros cannot be promoted into global scratch attributes');
|
|
139
|
+
for (const attribute of rest.split(/[ \t\r]+/).filter(Boolean)) {
|
|
140
|
+
if (attribute.startsWith('filter=')) unsupported('Git attribute filters require a separately controlled conversion adapter');
|
|
141
|
+
if (!attribute.startsWith('diff=')) continue;
|
|
142
|
+
const name = attribute.slice(5);
|
|
143
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) unsupported('Git diff driver name is outside the fixed metadata getter contract');
|
|
144
|
+
drivers.add(name);
|
|
145
|
+
}
|
|
146
|
+
if (macro) lines.push(checkedAttributeLine(`${gitQuote(pattern)}${rest ? ` ${rest}` : ''}`));
|
|
147
|
+
else if (rest) for (const side of ['before', 'after']) lines.push(checkedAttributeLine(`${gitQuote(prefixPattern(pattern, directory, side))} ${rest}`));
|
|
148
|
+
}
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { deriveAttribute as deriveBudgetGitAttributeSource };
|
|
153
|
+
|
|
154
|
+
function ancestorAttributes(paths) {
|
|
155
|
+
const directories = new Set(['']);
|
|
156
|
+
for (const relative of paths) {
|
|
157
|
+
const parts = relative.split('/');
|
|
158
|
+
for (let index = 1; index < parts.length; index++) directories.add(parts.slice(0, index).join('/'));
|
|
159
|
+
}
|
|
160
|
+
return [...directories].sort((left, right) => left.split('/').length - right.split('/').length || (left < right ? -1 : left > right ? 1 : 0));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function binaryDrivers(names, metadata) {
|
|
164
|
+
const result = [];
|
|
165
|
+
for (const name of [...names].sort()) {
|
|
166
|
+
const output = await inspect(metadata, ['config', '--bool', '--get', `diff.${name}.binary`], [1]);
|
|
167
|
+
if (output === null) continue;
|
|
168
|
+
const value = output.trim();
|
|
169
|
+
if (!['true', 'false'].includes(value)) scopeFail('ENTROROVE_GIT_FAILED', 'Git diff binary configuration is invalid');
|
|
170
|
+
result.push({ name, binary: value === 'true' });
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Internal options select Git command direction; never take them from the public task input. */
|
|
176
|
+
export async function captureScopeGitAttributes(root, paths, input, metadata, readIndexAttribute, options = {}) {
|
|
177
|
+
if (!Array.isArray(paths) || !paths.length) scopeFail('ENTROROVE_SCOPE_EMPTY', 'Git attributes require a non-empty target scope');
|
|
178
|
+
const requested = [...new Set(paths.map(scopePath))];
|
|
179
|
+
if (typeof metadata !== 'function') scopeFail('ENTROROVE_GIT_FAILED', 'Git attributes require a metadata adapter');
|
|
180
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => key !== 'indexFallback')
|
|
181
|
+
|| (Object.hasOwn(options, 'indexFallback') && typeof options.indexFallback !== 'boolean')) scopeFail('ENTROROVE_GIT_FAILED', 'Git attribute capture options are invalid');
|
|
182
|
+
const canonicalRoot = await scopeRoot(root);
|
|
183
|
+
const gitDirectory = await optionalStat(path.join(canonicalRoot, '.git'));
|
|
184
|
+
if (!gitDirectory?.isDirectory() || gitDirectory.isSymbolicLink()) scopeFail('ENTROROVE_BUDGET_SCOPE', 'Git attribute capture requires an actual root-contained Git directory');
|
|
185
|
+
const sources = [];
|
|
186
|
+
const system = await systemAttribute(canonicalRoot, input, metadata);
|
|
187
|
+
if (system) sources.push({ ...system, directory: '' });
|
|
188
|
+
const configured = await configuredAttribute(canonicalRoot, input, metadata);
|
|
189
|
+
if (configured) sources.push({ ...configured, directory: '' });
|
|
190
|
+
for (const directory of ancestorAttributes(requested)) {
|
|
191
|
+
const relative = directory ? `${directory}/.gitattributes` : '.gitattributes';
|
|
192
|
+
const source = await liveAttribute(canonicalRoot, relative, input) ?? (options.indexFallback === false ? null : await indexAttribute(relative, metadata, readIndexAttribute));
|
|
193
|
+
if (source) sources.push({ ...source, directory });
|
|
194
|
+
}
|
|
195
|
+
const info = await liveAttribute(canonicalRoot, '.git/info/attributes', input);
|
|
196
|
+
if (info) sources.push({ ...info, directory: '' });
|
|
197
|
+
const names = new Set();
|
|
198
|
+
const lines = sources.flatMap(source => deriveAttribute(source.content, source.directory, names, source.evidence.sourceKind));
|
|
199
|
+
return { attributeBinding: await sealBudgetedGitAttributes(input, sources), attributes: lines.length ? `${lines.join('\n')}\n` : '', metadata: sources.map(source => source.evidence), binaryDrivers: await binaryDrivers(names, metadata) };
|
|
200
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { compareBudgetedGitSources } from './local-budget-git-scratch.mjs';
|
|
2
|
+
import { scopeFail } from './local-scope-files.mjs';
|
|
3
|
+
|
|
4
|
+
export async function compareScopeGitSources(before, after, options) {
|
|
5
|
+
if (!options || typeof options !== 'object' || !options.input) scopeFail('ENTROROVE_BUDGET_BINDING_REQUIRED', 'Git comparison needs its original home, flow and repository input');
|
|
6
|
+
const { input, ...metadata } = options;
|
|
7
|
+
return compareBudgetedGitSources(input, before, after, metadata);
|
|
8
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { scopeFail, scopePath } from './local-scope-files.mjs';
|
|
2
|
+
|
|
3
|
+
const objectId = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
4
|
+
|
|
5
|
+
async function inspect(metadata, args) {
|
|
6
|
+
const output = await metadata(args, []);
|
|
7
|
+
if (typeof output !== 'string') scopeFail('ENTROROVE_GIT_FAILED', 'Git history metadata is unavailable');
|
|
8
|
+
return output;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function selectedPath(value, paths) {
|
|
12
|
+
return paths.some(selected => value === selected || value.startsWith(`${selected}/`));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function checkedPaths(files) {
|
|
16
|
+
if (!Array.isArray(files) || !files.length) scopeFail('ENTROROVE_SCOPE_EMPTY', 'Git history requires a non-empty target scope');
|
|
17
|
+
return [...new Set(files.map(scopePath))].sort();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readScopeGitTree(commit, requested, metadata) {
|
|
21
|
+
const paths = checkedPaths(requested);
|
|
22
|
+
if (typeof commit !== 'string' || !objectId.test(commit) || typeof metadata !== 'function') scopeFail('ENTROROVE_GIT_FAILED', 'Git tree requires a valid object ID and metadata adapter');
|
|
23
|
+
const output = await inspect(metadata, ['ls-tree', '-r', '-z', '--full-tree', commit, '--', ...paths.map(value => `:(literal)${value}`)]);
|
|
24
|
+
if (output && !output.endsWith('\0')) scopeFail('ENTROROVE_GIT_FAILED', 'Git tree metadata is truncated');
|
|
25
|
+
const entries = [];
|
|
26
|
+
for (const record of output.split('\0').filter(Boolean)) {
|
|
27
|
+
const separator = record.indexOf('\t');
|
|
28
|
+
const match = /^([0-7]{6}) (blob|tree|commit) ([0-9a-f]+)$/.exec(record.slice(0, separator));
|
|
29
|
+
if (separator < 0 || !match || !objectId.test(match[3])) scopeFail('ENTROROVE_GIT_FAILED', 'Git tree metadata is invalid');
|
|
30
|
+
const relative = scopePath(record.slice(separator + 1));
|
|
31
|
+
if (!selectedPath(relative, paths)) scopeFail('ENTROROVE_GIT_FAILED', 'Git tree metadata exceeds the requested scope');
|
|
32
|
+
if (match[2] === 'blob') entries.push({ path: relative, mode: match[1], objectId: match[3], commitId: commit });
|
|
33
|
+
}
|
|
34
|
+
entries.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
35
|
+
if (entries.some((entry, index) => index > 0 && entry.path === entries[index - 1].path)) scopeFail('ENTROROVE_GIT_FAILED', 'Git tree metadata repeats a path');
|
|
36
|
+
return entries;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sameTree(before, after) {
|
|
40
|
+
return before.length === after.length && before.every((entry, index) => entry.path === after[index].path
|
|
41
|
+
&& entry.mode === after[index].mode && entry.objectId === after[index].objectId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function commitStat(commit, paths, metadata, compare) {
|
|
45
|
+
const lineage = (await inspect(metadata, ['rev-list', '--parents', '-n', '1', commit])).trim().split(/\s+/);
|
|
46
|
+
if (lineage[0] !== commit || lineage.some(value => !objectId.test(value))) scopeFail('ENTROROVE_GIT_FAILED', 'Git commit ancestry is invalid');
|
|
47
|
+
const after = await readScopeGitTree(commit, paths, metadata);
|
|
48
|
+
let before = [];
|
|
49
|
+
for (const [index, parent] of lineage.slice(1).entries()) {
|
|
50
|
+
const parentEntries = await readScopeGitTree(parent, paths, metadata);
|
|
51
|
+
// Git's path-limited show suppresses a merge TREESAME to any parent for the whole selected set.
|
|
52
|
+
if (sameTree(parentEntries, after)) return { commit, changedLines: 0, binary: false };
|
|
53
|
+
if (index === 0) before = parentEntries;
|
|
54
|
+
}
|
|
55
|
+
if (sameTree(before, after)) return { commit, changedLines: 0, binary: false };
|
|
56
|
+
const result = await compare(before, after);
|
|
57
|
+
if (!result || !Number.isSafeInteger(result.lines) || result.lines < 0 || typeof result.binary !== 'boolean'
|
|
58
|
+
|| typeof result.present !== 'boolean') scopeFail('ENTROROVE_GIT_FAILED', 'Git history comparison returned invalid facts');
|
|
59
|
+
return { commit, changedLines: result.lines, binary: result.binary };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Metadata never reads blobs; compare owns all source reads and their original-path budget bindings. */
|
|
63
|
+
export async function collectBudgetGitHistory(files, metadata, compare) {
|
|
64
|
+
const paths = checkedPaths(files);
|
|
65
|
+
if (typeof metadata !== 'function' || typeof compare !== 'function') scopeFail('ENTROROVE_GIT_FAILED', 'Git history requires metadata and controlled comparison adapters');
|
|
66
|
+
const output = await inspect(metadata, ['log', '-5', '--format=%H', '--', ...paths.map(value => `:(literal)${value}`)]);
|
|
67
|
+
const recent = output.trim().split('\n').filter(Boolean);
|
|
68
|
+
if (recent.length > 5 || recent.some(commit => !objectId.test(commit)) || new Set(recent).size !== recent.length) scopeFail('ENTROROVE_GIT_FAILED', 'Git history metadata is invalid');
|
|
69
|
+
const commits = [];
|
|
70
|
+
for (const commit of recent) commits.push(await commitStat(commit, paths, metadata, compare));
|
|
71
|
+
return { commits, recentChangedLines: Math.max(0, ...commits.map(record => record.changedLines)), binary: commits.some(record => record.binary) };
|
|
72
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { compiledGitSystemAttributes } from './local-budget-git-system-attributes.mjs';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { assertBudgetReadContext } from './local-budget-reads.mjs';
|
|
6
|
+
import { readBudgetedGitBlob } from './local-budget-git.mjs';
|
|
7
|
+
import { scopeFail, scopePath } from './local-scope-files.mjs';
|
|
8
|
+
|
|
9
|
+
const execute = promisify(execFile), objectIdPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
10
|
+
const configFields = new Set(['core.attributesfile', 'core.filemode', 'core.autocrlf', 'core.eol', 'diff.algorithm', 'diff.indentheuristic', 'diff.renames', 'diff.renamelimit']);
|
|
11
|
+
function metadataCommand(args) {
|
|
12
|
+
if (!Array.isArray(args) || args.some(value => typeof value !== 'string' || /[\0\r\n]/.test(value))) return false;
|
|
13
|
+
const [command, ...rest] = args;
|
|
14
|
+
if (command === 'var') return rest.length === 1 && rest[0] === 'GIT_ATTR_SYSTEM';
|
|
15
|
+
if (command === 'rev-parse') return rest.length === 1 && rest[0] === '--show-toplevel'
|
|
16
|
+
|| rest.length === 2 && rest[0] === '--verify' && !rest[1].startsWith('-');
|
|
17
|
+
if (command === 'rev-list') return rest.length === 4 && rest[0] === '--parents' && rest[1] === '-n' && rest[2] === '1' && objectIdPattern.test(rest[3]);
|
|
18
|
+
if (command === 'cat-file') return rest.length === 2 && rest[0] === '-s' && objectIdPattern.test(rest[1]);
|
|
19
|
+
if (command === 'config') {
|
|
20
|
+
if (rest.length === 2 && rest[0] === '--get' && configFields.has(rest[1].toLowerCase())) return true;
|
|
21
|
+
if (rest.join(' ').toLowerCase() === '--null --path --get core.attributesfile') return true;
|
|
22
|
+
return rest.length === 3 && rest[0] === '--bool' && rest[1] === '--get' && /^diff\.[A-Za-z0-9_.-]+\.binary$/.test(rest[2]);
|
|
23
|
+
}
|
|
24
|
+
const separator = rest.indexOf('--');
|
|
25
|
+
if (separator < 0 || rest.slice(separator + 1).some(value => !value.startsWith(':(literal)'))) return false;
|
|
26
|
+
for (const value of rest.slice(separator + 1)) scopePath(value.slice(10));
|
|
27
|
+
const flags = rest.slice(0, separator);
|
|
28
|
+
if (command === 'ls-files') return ['--cached -z', '--stage -z'].includes(flags.join(' '));
|
|
29
|
+
if (command === 'log') return flags.join(' ') === '-5 --format=%H';
|
|
30
|
+
return command === 'ls-tree' && flags.length === 4 && flags.slice(0, 3).join(' ') === '-r -z --full-tree' && objectIdPattern.test(flags[3]);
|
|
31
|
+
}
|
|
32
|
+
function settings(root, control, bytes, encoding) {
|
|
33
|
+
const deadline = control?.deadlineAt === undefined ? Date.now() + 10000 : Number(control.deadlineAt);
|
|
34
|
+
if (!Number.isFinite(deadline) || deadline <= Date.now()) scopeFail('ENTROROVE_BUDGET_LEASE_EXPIRED', 'Git source read authorization expired');
|
|
35
|
+
return { cwd: root, encoding, timeout: Math.max(1, Math.min(10000, deadline - Date.now())), maxBuffer: bytes,
|
|
36
|
+
signal: control?.signal,
|
|
37
|
+
env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))),
|
|
38
|
+
GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' } };
|
|
39
|
+
}
|
|
40
|
+
const safeFlags = ['--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'diff.external='];
|
|
41
|
+
export async function scopeGitMetadata(root, args, input, allowed = []) {
|
|
42
|
+
await assertBudgetReadContext(input);
|
|
43
|
+
if (!metadataCommand(args)) scopeFail('ENTROROVE_GIT_FAILED', 'Git metadata command is outside the fixed scope inspection vocabulary');
|
|
44
|
+
try { return (await execute('/usr/bin/git', [...safeFlags, ...args], settings(root, null, 2097152, 'utf8'))).stdout; }
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === 129 && args.length === 2 && args[0] === 'var' && args[1] === 'GIT_ATTR_SYSTEM') {
|
|
47
|
+
return `${await compiledGitSystemAttributes()}\n`;
|
|
48
|
+
}
|
|
49
|
+
if (allowed.includes(error.code)) return null;
|
|
50
|
+
scopeFail('ENTROROVE_GIT_FAILED', 'Git metadata inspection failed or exceeded its execution bound');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function readScopeGitBlob(root, entry, input) {
|
|
54
|
+
const relative = scopePath(entry.path);
|
|
55
|
+
if (!objectIdPattern.test(entry.objectId)) scopeFail('ENTROROVE_GIT_SOURCE_INVALID', 'Git source needs a validated immutable object');
|
|
56
|
+
const size = (await scopeGitMetadata(root, ['cat-file', '-s', entry.objectId], input)).trim();
|
|
57
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(size) || !Number.isSafeInteger(Number(size))) scopeFail('ENTROROVE_GIT_SOURCE_INVALID', 'Git blob byte size is invalid');
|
|
58
|
+
const bytes = Number(size);
|
|
59
|
+
const descriptor = { path: relative, bytes, objectId: entry.objectId, mode: entry.mode, role: 'git-blob', commitId: entry.commitId ?? null };
|
|
60
|
+
if (entry.sourceKind === 'index') descriptor.sourceKind = 'index';
|
|
61
|
+
const content = await readBudgetedGitBlob(input, descriptor);
|
|
62
|
+
if (!Buffer.isBuffer(content) || content.length !== bytes) scopeFail('ENTROROVE_GIT_SOURCE_INVALID', 'Git blob bytes do not match their actual declared object size');
|
|
63
|
+
const digest = createHash(entry.objectId.length === 40 ? 'sha1' : 'sha256').update(`blob ${content.length}\0`).update(content).digest('hex');
|
|
64
|
+
if (digest !== entry.objectId) scopeFail('ENTROROVE_GIT_SOURCE_INVALID', 'Git blob bytes do not match their selected immutable object identity');
|
|
65
|
+
return { ...entry, content };
|
|
66
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { budgetBindingForRead } from './local-budget-reads.mjs';
|
|
3
|
+
import { budgetLocation, loadBudget } from './local-budget-store.mjs';
|
|
4
|
+
import { compareScopeGitSources } from './local-scope-git-comparison.mjs';
|
|
5
|
+
import { captureScopeGitAttributes } from './local-scope-git-attributes.mjs';
|
|
6
|
+
import { collectBudgetGitHistory, readScopeGitTree } from './local-scope-git-history.mjs';
|
|
7
|
+
import { readScopeGitBlob, scopeGitMetadata } from './local-scope-git-reads.mjs';
|
|
8
|
+
import { scopeFail, scopePath, scopeRead } from './local-scope-files.mjs';
|
|
9
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
10
|
+
|
|
11
|
+
const oid = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
12
|
+
export async function scopeGitBudgetEnforced(input) {
|
|
13
|
+
const binding = await budgetBindingForRead(input);
|
|
14
|
+
return binding !== null && (await loadBudget(await budgetLocation(binding))).state.enforced;
|
|
15
|
+
}
|
|
16
|
+
function emptyGit(available) {
|
|
17
|
+
return { facts: { available, head: null, baseline: null, currentChangedLines: 0, recentChangedLines: 0,
|
|
18
|
+
binary: false, commits: [] }, currentDiffPresent: false };
|
|
19
|
+
}
|
|
20
|
+
function readIndex(output, paths) {
|
|
21
|
+
if (output && !output.endsWith('\0')) scopeFail('ENTROROVE_GIT_FAILED', 'Git index metadata is truncated');
|
|
22
|
+
const entries = new Map();
|
|
23
|
+
for (const record of output.split('\0').filter(Boolean)) {
|
|
24
|
+
const separator = record.indexOf('\t'), match = /^([0-7]{6}) ([0-9a-f]+) ([0-3])$/.exec(record.slice(0, separator));
|
|
25
|
+
if (!match || separator < 0 || !oid.test(match[2])) scopeFail('ENTROROVE_GIT_FAILED', 'Git index metadata is invalid');
|
|
26
|
+
const relative = scopePath(record.slice(separator + 1));
|
|
27
|
+
if (!paths.includes(relative)) scopeFail('ENTROROVE_GIT_FAILED', 'Git index metadata exceeds the selected source scope');
|
|
28
|
+
if (match[3] !== '0') scopeFail('ENTROROVE_GIT_SOURCE_INVALID', 'Unmerged Git index needs a controlled conflict comparison');
|
|
29
|
+
if (entries.has(relative)) scopeFail('ENTROROVE_GIT_FAILED', 'Git index metadata repeats a source path');
|
|
30
|
+
entries.set(relative, { path: relative, mode: match[1], objectId: match[2] });
|
|
31
|
+
}
|
|
32
|
+
return entries;
|
|
33
|
+
}
|
|
34
|
+
async function currentEntries(root, files, index, metadata, input) {
|
|
35
|
+
const configured = (await metadata(['config', '--get', 'core.filemode'], [1]))?.trim();
|
|
36
|
+
if (configured !== undefined && !['true', 'false'].includes(configured)) scopeFail('ENTROROVE_GIT_FAILED', 'Git filemode metadata is invalid');
|
|
37
|
+
const result = [];
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
const tracked = index.get(file.path);
|
|
40
|
+
if (file.exists === false || !tracked) continue;
|
|
41
|
+
const actual = await scopeRead(root, file.path, input);
|
|
42
|
+
if (actual.sha256 !== file.sha256 || actual.bytes !== file.bytes) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Git current source changed from the actual scope observation');
|
|
43
|
+
if (!['100644', '100755'].includes(tracked.mode)) scopeFail('ENTROROVE_GIT_SOURCE_INVALID', 'Git current scope requires actual regular files');
|
|
44
|
+
result.push({ path: file.path, mode: configured === 'false' ? tracked.mode : actual.mode & 0o111 ? '100755' : '100644',
|
|
45
|
+
objectId: null, commitId: null, content: actual.content });
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
async function diffOptions(metadata, attributes) {
|
|
50
|
+
const get = async field => (await metadata(['config', '--get', field], [1]))?.trim();
|
|
51
|
+
const options = { attributes: attributes.attributes, attributeBinding: attributes.attributeBinding, binaryDrivers: attributes.binaryDrivers };
|
|
52
|
+
const renames = await get('diff.renames'), algorithm = await get('diff.algorithm'), indent = await get('diff.indentheuristic');
|
|
53
|
+
if (renames !== undefined) {
|
|
54
|
+
if (!['true', 'false', 'copies', 'copy'].includes(renames)) scopeFail('ENTROROVE_GIT_FAILED', 'Git rename configuration is invalid');
|
|
55
|
+
options.renames = ['copy', 'copies'].includes(renames) ? 'copies' : renames === 'true';
|
|
56
|
+
}
|
|
57
|
+
if (algorithm !== undefined && algorithm !== 'default') options.algorithm = algorithm;
|
|
58
|
+
if (algorithm === 'default') options.algorithm = 'myers';
|
|
59
|
+
if (indent !== undefined) {
|
|
60
|
+
if (!['true', 'false'].includes(indent)) scopeFail('ENTROROVE_GIT_FAILED', 'Git indent configuration is invalid');
|
|
61
|
+
options.indentHeuristic = indent === 'true';
|
|
62
|
+
}
|
|
63
|
+
const autocrlf = await get('core.autocrlf'), eol = await get('core.eol'), renameLimit = await get('diff.renamelimit');
|
|
64
|
+
if (autocrlf !== undefined) options.autocrlf = autocrlf;
|
|
65
|
+
if (eol !== undefined) options.eol = eol;
|
|
66
|
+
if (renameLimit !== undefined) {
|
|
67
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(renameLimit) || !Number.isSafeInteger(Number(renameLimit))) scopeFail('ENTROROVE_GIT_FAILED', 'Git rename limit configuration is invalid');
|
|
68
|
+
options.renameLimit = Number(renameLimit);
|
|
69
|
+
}
|
|
70
|
+
return options;
|
|
71
|
+
}
|
|
72
|
+
async function blobEntries(root, entries, input, cache) {
|
|
73
|
+
const result = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const key = `${entry.path}:${entry.mode}:${entry.objectId}`;
|
|
76
|
+
if (!cache.has(key)) cache.set(key, (await readScopeGitBlob(root, entry, input)).content);
|
|
77
|
+
result.push({ ...entry, content: cache.get(key) });
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
export async function observeBudgetScopeGit(root, files, baselineRef, input) {
|
|
82
|
+
const metadata = (args, allowed = []) => scopeGitMetadata(root, args, input, allowed);
|
|
83
|
+
const top = await metadata(['rev-parse', '--show-toplevel'], [128]);
|
|
84
|
+
if (top === null) {
|
|
85
|
+
if (baselineRef) scopeFail('ENTROROVE_GIT_BASELINE', 'baselineRef requires an actual Git repository');
|
|
86
|
+
return emptyGit(false);
|
|
87
|
+
}
|
|
88
|
+
if (await fs.realpath(top.trim()) !== root) scopeFail('ENTROROVE_GIT_ROOT', 'scope root must match the Git repository root');
|
|
89
|
+
const head = (await metadata(['rev-parse', '--verify', 'HEAD'], [128]))?.trim();
|
|
90
|
+
if (head === undefined) {
|
|
91
|
+
if (baselineRef) scopeFail('ENTROROVE_GIT_BASELINE', 'baselineRef requires a resolvable commit');
|
|
92
|
+
return emptyGit(true);
|
|
93
|
+
}
|
|
94
|
+
if (baselineRef !== undefined && (typeof baselineRef !== 'string' || !baselineRef || baselineRef.startsWith('-')
|
|
95
|
+
|| /[\u0000-\u001f]/.test(baselineRef))) scopeFail('ENTROROVE_GIT_BASELINE', 'baselineRef is invalid');
|
|
96
|
+
const baseline = (await metadata(['rev-parse', '--verify', `${baselineRef ?? 'HEAD'}^{commit}`], [128]))?.trim();
|
|
97
|
+
if (!oid.test(head) || !oid.test(baseline ?? '')) scopeFail('ENTROROVE_GIT_BASELINE', 'baselineRef must resolve to a real commit');
|
|
98
|
+
const paths = files.map(file => scopePath(file.path)), cache = new Map();
|
|
99
|
+
const indexOutput = await metadata(['ls-files', '--stage', '-z', '--', ...paths.map(value => `:(literal)${value}`)]);
|
|
100
|
+
const index = readIndex(indexOutput, paths);
|
|
101
|
+
const capture = indexFallback => captureScopeGitAttributes(root, paths, input, metadata, async (_path, entry) => {
|
|
102
|
+
const actual = await readScopeGitBlob(root, entry, input);
|
|
103
|
+
return { content: actual.content, sha256: sha256(actual.content) };
|
|
104
|
+
}, { indexFallback });
|
|
105
|
+
const attributes = await capture(true), historyAttributes = await capture(false);
|
|
106
|
+
const options = await diffOptions(metadata, attributes);
|
|
107
|
+
const historyOptions = await diffOptions(metadata, historyAttributes);
|
|
108
|
+
const before = await blobEntries(root, await readScopeGitTree(baseline, paths, metadata), input, cache);
|
|
109
|
+
const after = await currentEntries(root, files, index, metadata, input);
|
|
110
|
+
const compare = async (left, right, settings, historical = false) => compareScopeGitSources(left, right, { ...settings, input,
|
|
111
|
+
attributes: `${settings.attributes}before/** -text -working-tree-encoding -filter -ident\n${historical ? 'after/** -text -working-tree-encoding -filter -ident\n' : ''}` });
|
|
112
|
+
const current = await compare(before, after, options);
|
|
113
|
+
const history = await collectBudgetGitHistory(paths, metadata, async (left, right) => compare(
|
|
114
|
+
await blobEntries(root, left, input, cache), await blobEntries(root, right, input, cache), historyOptions, true));
|
|
115
|
+
const untracked = files.filter(file => file.exists !== false && !index.has(file.path));
|
|
116
|
+
const untrackedChangedLines = untracked.reduce((total, file) => total + file.lines, 0);
|
|
117
|
+
if (canonical(await capture(true)) !== canonical(attributes) || canonical(await capture(false)) !== canonical(historyAttributes)
|
|
118
|
+
|| canonical(await diffOptions(metadata, attributes)) !== canonical(options)) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Actual Git attribute sources or diff metadata changed during comparison');
|
|
119
|
+
if (await metadata(['ls-files', '--stage', '-z', '--', ...paths.map(value => `:(literal)${value}`)]) !== indexOutput
|
|
120
|
+
|| (await metadata(['rev-parse', '--verify', 'HEAD'], [128]))?.trim() !== head) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Git metadata changed during source comparison');
|
|
121
|
+
return { facts: { available: true, head, baseline, currentChangedLines: current.lines + untrackedChangedLines,
|
|
122
|
+
untrackedChangedLines, recentChangedLines: history.recentChangedLines, binary: current.binary || history.binary,
|
|
123
|
+
commits: history.commits }, currentDiffPresent: current.present || untracked.length > 0 };
|
|
124
|
+
}
|