testguard-cli 0.16.0 → 0.18.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 +70 -0
- package/README.md +169 -14
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +316 -0
- package/spec/README.md +27 -2
- package/spec/lib/authoring-input.mjs +40 -0
- package/spec/lib/origins.mjs +56 -0
- package/spec/lib/validate.mjs +123 -1
- package/spec/schemas/annotations.schema.json +65 -0
- package/spec/schemas/authoring-input.schema.json +67 -0
- package/spec/schemas/brief.schema.json +2 -0
- package/spec/schemas/claims.schema.json +2 -8
- package/spec/schemas/common.schema.json +73 -0
- package/spec/schemas/evidence.schema.json +40 -10
- package/spec/schemas/status.schema.json +6 -2
- package/src/brief/brief.mjs +11 -4
- package/src/claims/annotation-placement.mjs +357 -0
- package/src/claims/annotations.mjs +61 -2
- package/src/cli.mjs +41 -6
- package/src/commands/admit.mjs +13 -0
- package/src/commands/annotations.mjs +67 -0
- package/src/commands/claims.mjs +8 -4
- package/src/commands/probe.mjs +50 -7
- package/src/commands/replay.mjs +7 -0
- package/src/commands/scaffold.mjs +49 -2
- package/src/commands/sweep.mjs +7 -0
- package/src/evidence/writer.mjs +12 -4
- package/src/git.mjs +4 -4
- package/src/init/templates/SKILL.md +35 -0
- package/src/probe/attribution.mjs +10 -0
- package/src/probe/contention.mjs +20 -2
- package/src/probe/cost.mjs +6 -1
- package/src/probe/inject.mjs +2 -6
- package/src/probe/origin-policy.mjs +69 -0
- package/src/probe/policy-freshness.mjs +74 -0
- package/src/probe/probe.mjs +49 -24
- package/src/probe/runners/discovery.mjs +4 -0
- package/src/probe/runners/index.mjs +3 -1
- package/src/probe/runners/jest.mjs +2 -2
- package/src/probe/runners/lifecycle.mjs +58 -0
- package/src/probe/runners/node/entry.mjs +11 -0
- package/src/probe/runners/node/report.mjs +66 -0
- package/src/probe/runners/node/runner.mjs +42 -0
- package/src/probe/runners/node-test.mjs +130 -0
- package/src/probe/runners/playwright.mjs +6 -2
- package/src/probe/runners/python.mjs +10 -4
- package/src/probe/runners/shared.mjs +14 -7
- package/src/probe/runners/vitest.mjs +3 -3
- package/src/probe/universe.mjs +47 -0
- package/src/probe/worktree.mjs +3 -1
- package/src/render.mjs +17 -1
- package/src/replay/replay.mjs +8 -4
- package/src/scaffold/admission.mjs +117 -0
- package/src/scaffold/append.mjs +41 -0
- package/src/scaffold/apply-append.mjs +71 -0
- package/src/scaffold/fix-input.mjs +119 -0
- package/src/scaffold/fix-paths.mjs +90 -0
- package/src/scaffold/intent.mjs +2 -0
- package/src/scaffold/open-append.mjs +44 -0
- package/src/scaffold/plan-append.mjs +37 -0
- package/src/scaffold/plan-intent-input.mjs +38 -0
- package/src/scaffold/prepare-append.mjs +46 -0
- package/src/scaffold/request.mjs +30 -0
- package/src/scaffold/scaffold.mjs +23 -10
- package/src/status/status.mjs +34 -9
- package/src/sweep/pool.mjs +33 -0
- package/src/sweep/sweep.mjs +9 -4
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { fstatSync, fsyncSync, ftruncateSync, readSync, writeSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { persistRecovery } from '../claims/annotation-placement.mjs';
|
|
5
|
+
import { readSpecDoc, writeSpecDoc } from '../evidence/writer.mjs';
|
|
6
|
+
import { admitDraftAppend } from './admission.mjs';
|
|
7
|
+
import { openDraftAppend } from './open-append.mjs';
|
|
8
|
+
|
|
9
|
+
/** Explicit internal draft publication. Partial writes never auto-rollback. */
|
|
10
|
+
export function applyDraftAppend({ request, plan }) {
|
|
11
|
+
let handle, stage = 'preparation';
|
|
12
|
+
const report = { ok: false, state: 'refused', writeAttempted: false, bytesWritten: 0, failed: [] };
|
|
13
|
+
const fail = (error) => {
|
|
14
|
+
report.ok = false; report.state = report.writeAttempted ? 'write-unconfirmed' : 'refused';
|
|
15
|
+
report.failed.push({ stage, reason: error.message });
|
|
16
|
+
};
|
|
17
|
+
try {
|
|
18
|
+
handle = openDraftAppend({ request, plan }); report.recoveryDir = handle.recoveryDir;
|
|
19
|
+
if (handle.plan.changed) {
|
|
20
|
+
stage = 'spec-publication';
|
|
21
|
+
writeSpecDoc('claims', handle.plan.draft.path, handle.plan.doc, { publish: (output) => {
|
|
22
|
+
if (output !== handle.plan.output) throw new Error('draft append serialized output changed');
|
|
23
|
+
stage = 'started-journal';
|
|
24
|
+
persistRecovery(join(handle.recoveryDir, 'started.json'), Buffer.from(JSON.stringify({ draft: handle.plan.draft.file, state: 'started' })));
|
|
25
|
+
stage = 'write-preflight'; handle.assertCurrent();
|
|
26
|
+
const bytes = Buffer.from(output, 'utf8');
|
|
27
|
+
stage = 'draft-write'; report.writeAttempted = true;
|
|
28
|
+
while (report.bytesWritten < bytes.length) {
|
|
29
|
+
const offset = report.bytesWritten;
|
|
30
|
+
const count = writeSync(handle.fd, bytes, offset, bytes.length - offset, offset);
|
|
31
|
+
if (!Number.isInteger(count) || count <= 0 || count > bytes.length - offset) throw new Error('draft write made no valid progress');
|
|
32
|
+
report.bytesWritten += count;
|
|
33
|
+
}
|
|
34
|
+
stage = 'truncate'; ftruncateSync(handle.fd, bytes.length);
|
|
35
|
+
stage = 'draft-fsync'; fsyncSync(handle.fd);
|
|
36
|
+
stage = 'verification'; handle.assertOwned();
|
|
37
|
+
const stat = fstatSync(handle.fd), identity = handle.plan.draft.identity;
|
|
38
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.dev !== identity.dev || stat.ino !== identity.ino || stat.mode !== identity.mode || stat.size !== bytes.length) throw new Error('draft descriptor verification failed');
|
|
39
|
+
const buffer = Buffer.alloc(bytes.length + 1); let length = 0;
|
|
40
|
+
while (length < buffer.length) {
|
|
41
|
+
const count = readSync(handle.fd, buffer, length, buffer.length - length, length);
|
|
42
|
+
if (!count) break;
|
|
43
|
+
length += count;
|
|
44
|
+
}
|
|
45
|
+
if (length !== bytes.length || !buffer.subarray(0, length).equals(bytes)) throw new Error('draft byte verification failed');
|
|
46
|
+
readSpecDoc('claims', handle.plan.draft.path, { source: buffer.subarray(0, length).toString('utf8') });
|
|
47
|
+
const current = admitDraftAppend(request);
|
|
48
|
+
if (current.projectDir !== handle.plan.projectDir || current.draft.source !== output || current.draft.identity.dev !== identity.dev || current.draft.identity.ino !== identity.ino || current.draft.identity.mode !== identity.mode || !isDeepStrictEqual(current.sources, handle.plan.sources)) throw new Error('draft or sources changed after write');
|
|
49
|
+
handle.assertOwned();
|
|
50
|
+
} });
|
|
51
|
+
report.state = 'updated';
|
|
52
|
+
} else { handle.assertCurrent(); report.state = 'unchanged'; }
|
|
53
|
+
report.ok = true;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.recoveryDir) report.recoveryDir = error.recoveryDir;
|
|
56
|
+
if (error.lockRelease) report.release = error.lockRelease;
|
|
57
|
+
fail(error);
|
|
58
|
+
} finally {
|
|
59
|
+
if (handle) {
|
|
60
|
+
report.release = handle.release();
|
|
61
|
+
if (!report.release.released) { stage = 'release'; fail(new Error(report.release.reason || 'resource release failed')); }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (report.recoveryDir) {
|
|
65
|
+
stage = 'result-journal';
|
|
66
|
+
try { persistRecovery(join(report.recoveryDir, 'result.json'), Buffer.from(JSON.stringify(report))); }
|
|
67
|
+
catch (error) { fail(error); }
|
|
68
|
+
}
|
|
69
|
+
report.failed.forEach(Object.freeze); Object.freeze(report.failed);
|
|
70
|
+
return Object.freeze(report);
|
|
71
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { lstatSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { devNull } from 'node:os';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { performance } from 'node:perf_hooks';
|
|
7
|
+
import { parseFixChangedPaths, parseFixProjectMarkers, validateFixProjectPrefix } from './fix-paths.mjs';
|
|
8
|
+
import { admitIntentProject } from './admission.mjs';
|
|
9
|
+
|
|
10
|
+
const OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
11
|
+
const MAX_BYTES = 128 * 1024;
|
|
12
|
+
function refuse(reason) { throw new Error(`fix input refused: ${reason}`); }
|
|
13
|
+
|
|
14
|
+
function localFixContext({ projectDir, commit, budgetMs = 5000 }) {
|
|
15
|
+
if (typeof commit !== 'string' || !OBJECT_ID.test(commit)) refuse('full-object-id-required');
|
|
16
|
+
if (!Number.isSafeInteger(budgetMs) || budgetMs < 1 || budgetMs > 5000) refuse('invalid-budget');
|
|
17
|
+
const started = performance.now();
|
|
18
|
+
const requestedRoot = resolve(projectDir);
|
|
19
|
+
if (lstatSync(requestedRoot).isSymbolicLink()) refuse('unsafe-root');
|
|
20
|
+
const root = realpathSync(requestedRoot);
|
|
21
|
+
const identity = lstatSync(root);
|
|
22
|
+
if (!identity.isDirectory()) refuse('unsafe-root');
|
|
23
|
+
const checkRoot = () => {
|
|
24
|
+
const current = lstatSync(requestedRoot);
|
|
25
|
+
if (current.isSymbolicLink() || !current.isDirectory() || current.dev !== identity.dev || current.ino !== identity.ino || realpathSync(requestedRoot) !== root) refuse('root-changed');
|
|
26
|
+
};
|
|
27
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
|
|
28
|
+
Object.assign(env, { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: devNull, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat', LC_ALL: 'C' });
|
|
29
|
+
let captured = 0;
|
|
30
|
+
const read = args => {
|
|
31
|
+
checkRoot();
|
|
32
|
+
const remaining = Math.floor(budgetMs - (performance.now() - started));
|
|
33
|
+
if (remaining < 1) refuse('deadline');
|
|
34
|
+
const run = spawnSync('git', ['--no-lazy-fetch', '--no-replace-objects', '-c', 'core.fsmonitor=false', '-c', `core.hooksPath=${devNull}`, ...args], {
|
|
35
|
+
cwd: root, env, shell: false, timeout: remaining, maxBuffer: MAX_BYTES,
|
|
36
|
+
});
|
|
37
|
+
if (run.error || run.signal || run.status !== 0 || performance.now() - started >= budgetMs) refuse('object-read-unavailable');
|
|
38
|
+
if (!Buffer.isBuffer(run.stdout) || run.stdout.length > MAX_BYTES) refuse('object-byte-limit');
|
|
39
|
+
captured += run.stdout.length;
|
|
40
|
+
if (captured > 512 * 1024) refuse('aggregate-byte-limit');
|
|
41
|
+
checkRoot();
|
|
42
|
+
return run.stdout;
|
|
43
|
+
};
|
|
44
|
+
const finish = () => {
|
|
45
|
+
checkRoot();
|
|
46
|
+
if (budgetMs <= performance.now() - started) refuse('deadline');
|
|
47
|
+
};
|
|
48
|
+
return { root, read, finish };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function commitMetadata(context, commit) {
|
|
52
|
+
const bytes = context.read(['cat-file', 'commit', commit]);
|
|
53
|
+
const hash = createHash(commit.length === 40 ? 'sha1' : 'sha256').update(`commit ${bytes.length}\0`).update(bytes).digest('hex');
|
|
54
|
+
if (hash !== commit) refuse('object-identity-mismatch');
|
|
55
|
+
let text;
|
|
56
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
|
|
57
|
+
catch { refuse('invalid-utf8'); }
|
|
58
|
+
if (text.includes('\0')) refuse('invalid-encoding');
|
|
59
|
+
const separator = text.indexOf('\n\n');
|
|
60
|
+
if (separator < 0) refuse('malformed-commit');
|
|
61
|
+
const parents = text.slice(0, separator).split('\n').filter(line => line.startsWith('parent ')).map(line => line.slice(7));
|
|
62
|
+
if (parents.length !== 1 || !OBJECT_ID.test(parents[0]) || parents[0].length !== commit.length) refuse('single-parent-required');
|
|
63
|
+
const subject = text.slice(separator + 2).split('\n', 1)[0];
|
|
64
|
+
if (Buffer.byteLength(subject) > 1024 || /[\x00-\x1f\x7f-\x9f]/.test(subject)) refuse('unsafe-subject');
|
|
65
|
+
return Object.freeze({ commit, parent: parents[0], subject });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Local immutable metadata, not a bug claim or an authenticated intent source. */
|
|
69
|
+
export function readFixCommitMetadata(options) {
|
|
70
|
+
const context = localFixContext(options);
|
|
71
|
+
const metadata = commitMetadata(context, options.commit);
|
|
72
|
+
context.finish();
|
|
73
|
+
return metadata;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Complete bounded historical diff for an exact worktree root, not current source authority. */
|
|
77
|
+
export function readFixCommitInventory(options) { return fixInventory(options, false); }
|
|
78
|
+
|
|
79
|
+
/** Explicit selected nested project; historical inventory is not current source authority. */
|
|
80
|
+
export function readNestedFixCommitInventory(options) { return fixInventory(options, true); }
|
|
81
|
+
|
|
82
|
+
/** Observe root/nested selection once, under one unchanged offline Git context. */
|
|
83
|
+
export function readScopedFixCommitInventory(options) { return fixInventory(options, 'selected'); }
|
|
84
|
+
|
|
85
|
+
function fixInventory(options, mode) {
|
|
86
|
+
const context = localFixContext(options);
|
|
87
|
+
let toplevel;
|
|
88
|
+
try { toplevel = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }).decode(context.read(['rev-parse', '--show-toplevel'])); }
|
|
89
|
+
catch (error) { if (error instanceof TypeError) refuse('invalid-root-encoding'); throw error; }
|
|
90
|
+
const scoped = mode === 'selected';
|
|
91
|
+
const nested = scoped ? toplevel.endsWith('\n') && toplevel.slice(0, -1) !== context.root : mode;
|
|
92
|
+
let projectPrefix = '', selected;
|
|
93
|
+
if (!nested) {
|
|
94
|
+
if (!toplevel.endsWith('\n') || toplevel.slice(0, -1) !== context.root) refuse('project-root-required');
|
|
95
|
+
} else {
|
|
96
|
+
if (!toplevel.endsWith('\n')) refuse('project-root-required');
|
|
97
|
+
const repositoryRoot = realpathSync(toplevel.slice(0, -1));
|
|
98
|
+
const prefix = relative(repositoryRoot, context.root);
|
|
99
|
+
if (!prefix) refuse('nested-project-required');
|
|
100
|
+
if (isAbsolute(prefix) || prefix === '..' || prefix.startsWith(`..${sep}`)) refuse('outside-project');
|
|
101
|
+
projectPrefix = validateFixProjectPrefix(prefix.split(sep).join('/'));
|
|
102
|
+
selected = admitIntentProject({ projectDir: context.root });
|
|
103
|
+
}
|
|
104
|
+
const metadata = commitMetadata(context, options.commit);
|
|
105
|
+
const tree = id => context.read(['ls-tree', '-r', '--name-only', '-z', '--full-tree', id]);
|
|
106
|
+
const projects = parseFixProjectMarkers(tree(metadata.parent), tree(metadata.commit), { projectPrefix });
|
|
107
|
+
const args = ['diff-tree', '--raw', '--no-abbrev', '-z', '--no-renames', '--no-ext-diff', '--no-textconv', '-r', '--no-commit-id', metadata.parent, metadata.commit, '--'];
|
|
108
|
+
if (projectPrefix) args.push(`:(top,literal)${projectPrefix}/`);
|
|
109
|
+
const raw = context.read(args);
|
|
110
|
+
const inventory = parseFixChangedPaths(raw, { nestedProjects: projects, objectIdWidth: metadata.commit.length, projectPrefix });
|
|
111
|
+
if (selected) {
|
|
112
|
+
const current = admitIntentProject({ projectDir: context.root });
|
|
113
|
+
if (current.projectDir !== selected.projectDir || current.marker.hash !== selected.marker.hash ||
|
|
114
|
+
Object.entries(selected.marker.identity).some(([key, value]) => current.marker.identity[key] !== value)) refuse('project-marker-changed');
|
|
115
|
+
}
|
|
116
|
+
context.finish();
|
|
117
|
+
if (scoped) return Object.freeze({ scope: projectPrefix ? 'selected-nested-project' : 'project-root', ...metadata, ...inventory });
|
|
118
|
+
return Object.freeze({ ...metadata, ...inventory });
|
|
119
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const PRIVATE = new Set(['.local', '.wolf', '.git', '.testguard', 'graphify-out']);
|
|
4
|
+
const SUPPORTED = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py']);
|
|
5
|
+
function refuse(reason) { throw new Error(`fix inventory refused: ${reason}`); }
|
|
6
|
+
function safePath(file) {
|
|
7
|
+
if (!file || file.includes('\\') || /[\x00-\x1f\x7f-\x9f]/.test(file) || file.split('/').some(part => !part || part === '.' || part === '..') || /^[A-Za-z]:/.test(file)) refuse('unsafe-path');
|
|
8
|
+
if (Buffer.byteLength(file) > 4096) refuse('path-byte-limit');
|
|
9
|
+
return file;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateFixProjectPrefix(prefix) {
|
|
13
|
+
if (typeof prefix !== 'string') refuse('unsafe-project-prefix');
|
|
14
|
+
if (!prefix) return prefix;
|
|
15
|
+
safePath(prefix);
|
|
16
|
+
if (prefix.split('/').some(part => PRIVATE.has(part))) refuse('excluded-project');
|
|
17
|
+
return prefix;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Complete historical marker union only; current filesystem admission remains separate. */
|
|
21
|
+
export function parseFixProjectMarkers(parentRaw, commitRaw, { projectPrefix = '' } = {}) {
|
|
22
|
+
validateFixProjectPrefix(projectPrefix);
|
|
23
|
+
const projects = new Set();
|
|
24
|
+
for (const raw of [parentRaw, commitRaw]) {
|
|
25
|
+
if (!Buffer.isBuffer(raw)) refuse('buffer-required');
|
|
26
|
+
if (raw.length > 128 * 1024) refuse('output-byte-limit');
|
|
27
|
+
let listing;
|
|
28
|
+
try { listing = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }).decode(raw); }
|
|
29
|
+
catch { refuse('invalid-utf8'); }
|
|
30
|
+
if (listing && !listing.endsWith('\0')) refuse('truncated-output');
|
|
31
|
+
const files = listing ? listing.slice(0, -1).split('\0') : [];
|
|
32
|
+
if (files.length > 4096) refuse('tree-name-count-limit');
|
|
33
|
+
const namesSeen = new Set();
|
|
34
|
+
for (const name of files) {
|
|
35
|
+
const file = safePath(name);
|
|
36
|
+
if (namesSeen.has(file)) refuse('duplicate-path');
|
|
37
|
+
namesSeen.add(file);
|
|
38
|
+
if (!file.endsWith('/testguard.claims.json')) continue;
|
|
39
|
+
const project = file.slice(0, -'/testguard.claims.json'.length);
|
|
40
|
+
projects.add(project);
|
|
41
|
+
if (projects.size > 256) refuse('project-count-limit');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (projectPrefix) {
|
|
45
|
+
const descendants = [...projects].filter(project => project.startsWith(`${projectPrefix}/`));
|
|
46
|
+
projects.clear();
|
|
47
|
+
for (const project of descendants) projects.add(project.slice(projectPrefix.length + 1));
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze([...projects].filter(project => !project.split('/').some(part => PRIVATE.has(part))).sort());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Decode complete raw Git data only; supplied exclusions are not discovery authority. */
|
|
53
|
+
export function parseFixChangedPaths(raw, { nestedProjects = [], objectIdWidth, projectPrefix = '' } = {}) {
|
|
54
|
+
validateFixProjectPrefix(projectPrefix);
|
|
55
|
+
if (!Buffer.isBuffer(raw)) refuse('buffer-required');
|
|
56
|
+
if (objectIdWidth !== undefined && objectIdWidth !== 40 && objectIdWidth !== 64) refuse('invalid-object-width');
|
|
57
|
+
if (raw.length > 128 * 1024) refuse('output-byte-limit');
|
|
58
|
+
if (!Array.isArray(nestedProjects) || nestedProjects.length > 256) refuse('project-count-limit');
|
|
59
|
+
const projects = nestedProjects.map(safePath);
|
|
60
|
+
let text;
|
|
61
|
+
try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw); }
|
|
62
|
+
catch { refuse('invalid-utf8'); }
|
|
63
|
+
if (text && !text.endsWith('\0')) refuse('truncated-output');
|
|
64
|
+
const parts = text ? text.slice(0, -1).split('\0') : [];
|
|
65
|
+
if (parts.length % 2) refuse('malformed-record');
|
|
66
|
+
if (parts.length / 2 > 256) refuse('path-count-limit');
|
|
67
|
+
const paths = [], seen = new Set();
|
|
68
|
+
const counts = { total: 0, supported: 0, deleted: 0, unsupported: 0, excluded: 0 };
|
|
69
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
70
|
+
const match = /^:([0-7]{6}) ([0-7]{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([a-f0-9]{40}|[a-f0-9]{64}) ([AMDT])$/.exec(parts[i]);
|
|
71
|
+
if (!match || match[3].length !== match[4].length) refuse('malformed-record');
|
|
72
|
+
const [, oldMode, newMode, oldId, newId, status] = match;
|
|
73
|
+
if (objectIdWidth !== undefined && oldId.length !== objectIdWidth) refuse('object-width-mismatch');
|
|
74
|
+
const oldZero = /^0+$/.test(oldId), newZero = /^0+$/.test(newId);
|
|
75
|
+
if ((oldMode === '000000') !== oldZero || (newMode === '000000') !== newZero ||
|
|
76
|
+
(status === 'A' ? !oldZero || newZero : status === 'D' ? oldZero || !newZero : oldZero || newZero)) refuse('inconsistent-identity');
|
|
77
|
+
const repositoryFile = safePath(parts[i + 1]);
|
|
78
|
+
if (projectPrefix && !repositoryFile.startsWith(`${projectPrefix}/`)) refuse('out-of-scope-path');
|
|
79
|
+
const file = projectPrefix ? repositoryFile.slice(projectPrefix.length + 1) : repositoryFile;
|
|
80
|
+
if (seen.has(file)) refuse('duplicate-path');
|
|
81
|
+
seen.add(file); counts.total++;
|
|
82
|
+
if (file.split('/').some(part => PRIVATE.has(part)) || projects.some(project => file === project || file.startsWith(`${project}/`))) {
|
|
83
|
+
counts.excluded++; continue;
|
|
84
|
+
}
|
|
85
|
+
const disposition = status === 'D' ? 'deleted' : !['100644', '100755'].includes(newMode) || !SUPPORTED.has(extname(file)) ? 'unsupported' : 'supported';
|
|
86
|
+
counts[disposition]++;
|
|
87
|
+
paths.push(Object.freeze({ file, status, oldMode, newMode, oldId, newId, disposition }));
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze({ paths: Object.freeze(paths), counts: Object.freeze(counts) });
|
|
90
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Authoring advice, not an authenticated origin or a verification result.
|
|
2
|
+
export const intentHandoff = 'Supply intended observable behavior from a requirement, ADR, bug or incident independently of the implementation: name inputs, expected outcome and forbidden outcome. Record the supplied source kind/ref; when unavailable, keep inferred. Source references and annotations are not authenticated intent. Review each proposal against that intent before probing.';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, openSync } from 'node:fs';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { planDraftAppend } from './plan-append.mjs';
|
|
4
|
+
import { prepareDraftAppend } from './prepare-append.mjs';
|
|
5
|
+
|
|
6
|
+
/** Acquire a checked descriptor; no draft writes and no implicit truncation. */
|
|
7
|
+
export function openDraftAppend({ request, plan }) {
|
|
8
|
+
const prepared = prepareDraftAppend({ request, plan });
|
|
9
|
+
let fd, closed = false, releaseResult;
|
|
10
|
+
function release() {
|
|
11
|
+
if (closed) return releaseResult;
|
|
12
|
+
closed = true;
|
|
13
|
+
let closeError;
|
|
14
|
+
try { if (fd !== undefined) closeSync(fd); } catch (error) { closeError = error; }
|
|
15
|
+
let lockRelease;
|
|
16
|
+
try { lockRelease = prepared.release(); }
|
|
17
|
+
catch (error) { lockRelease = { released: false, reason: 'lock-release-failed', error: error.message }; }
|
|
18
|
+
releaseResult = closeError ? { released: false, reason: 'descriptor-close-failed', error: closeError.message, lockRelease } : lockRelease;
|
|
19
|
+
return releaseResult;
|
|
20
|
+
}
|
|
21
|
+
function assertCurrent() {
|
|
22
|
+
if (closed) throw new Error('draft append descriptor closed');
|
|
23
|
+
prepared.assertOwned();
|
|
24
|
+
if (!isDeepStrictEqual(planDraftAppend(request), prepared.plan)) throw new Error('draft append inputs changed before write');
|
|
25
|
+
if (fd !== undefined) {
|
|
26
|
+
const stat = fstatSync(fd);
|
|
27
|
+
if (!stat.isFile() || stat.nlink !== 1 || Object.entries(prepared.plan.draft.identity).some(([key, value]) => stat[key] !== value)) throw new Error('draft append descriptor changed');
|
|
28
|
+
}
|
|
29
|
+
prepared.assertOwned();
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
assertCurrent();
|
|
33
|
+
if (prepared.plan.changed) {
|
|
34
|
+
if (constants.O_NOFOLLOW === undefined) throw new Error('draft append nofollow unavailable');
|
|
35
|
+
fd = openSync(prepared.plan.draft.path, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
36
|
+
assertCurrent();
|
|
37
|
+
}
|
|
38
|
+
return Object.freeze({ ...prepared, fd, assertCurrent, release });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (prepared.recoveryDir) error.recoveryDir = prepared.recoveryDir;
|
|
41
|
+
error.lockRelease = release();
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { admitDraftAppend } from './admission.mjs';
|
|
3
|
+
import { appendDraft } from './append.mjs';
|
|
4
|
+
import { scaffoldFile } from './scaffold.mjs';
|
|
5
|
+
|
|
6
|
+
const MAX_PROPOSALS = 4096;
|
|
7
|
+
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function boundedLimit(value, ceiling, name) {
|
|
10
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > ceiling) throw new Error(`${name} must be a non-negative safe integer no greater than ${ceiling}`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function outputOf(doc, maxOutputBytes) {
|
|
15
|
+
const output = JSON.stringify(doc, null, 2) + '\n';
|
|
16
|
+
if (Buffer.byteLength(output, 'utf8') > maxOutputBytes) throw new Error('draft append output byte limit exceeded');
|
|
17
|
+
return output;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read-only generation. Lower internal limits may tighten, never raise caps. */
|
|
21
|
+
export function planDraftAppend({ maxProposals = MAX_PROPOSALS, maxOutputBytes = MAX_OUTPUT_BYTES, toolVersion = '0.0.0', ...request }) {
|
|
22
|
+
boundedLimit(maxProposals, MAX_PROPOSALS, 'maxProposals');
|
|
23
|
+
boundedLimit(maxOutputBytes, MAX_OUTPUT_BYTES, 'maxOutputBytes');
|
|
24
|
+
const inputs = admitDraftAppend(request);
|
|
25
|
+
let doc = structuredClone(inputs.draft.doc);
|
|
26
|
+
let output = outputOf(doc, maxOutputBytes);
|
|
27
|
+
let generated = 0;
|
|
28
|
+
const initialFaults = doc.claims.find((claim) => claim.id === inputs.claimId).faults.length;
|
|
29
|
+
for (const input of inputs.sources) {
|
|
30
|
+
const proposed = scaffoldFile({ projectDir: inputs.projectDir, file: input.file, source: input.source, claimId: inputs.claimId, toolVersion, maxProposals: maxProposals - generated });
|
|
31
|
+
generated += proposed.stats.proposals;
|
|
32
|
+
doc = appendDraft({ draft: doc, proposals: proposed.doc, claimId: inputs.claimId });
|
|
33
|
+
output = outputOf(doc, maxOutputBytes);
|
|
34
|
+
}
|
|
35
|
+
const appended = doc.claims.find((claim) => claim.id === inputs.claimId).faults.length - initialFaults;
|
|
36
|
+
return { ...inputs, doc, output, changed: !isDeepStrictEqual(doc, inputs.draft.doc), stats: { targets: inputs.sources.length, generated, appended } };
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { intentInputRequest } from './request.mjs';
|
|
3
|
+
import { admitIntentDocument } from './admission.mjs';
|
|
4
|
+
import { readScopedFixCommitInventory } from './fix-input.mjs';
|
|
5
|
+
import { writeSpecDoc } from '../evidence/writer.mjs';
|
|
6
|
+
|
|
7
|
+
function freezeOwned(value) {
|
|
8
|
+
if (value && typeof value === 'object') {
|
|
9
|
+
for (const child of Object.values(value)) freezeOwned(child);
|
|
10
|
+
Object.freeze(value);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Validated read-only metadata, not a claim, a measurement or write authorization. */
|
|
16
|
+
export function planIntentInput({ projectDir, command = 'scaffold', values, suppliedOptions = [], files = [], toolVersion = '0.0.0', budgetMs = 5000 }) {
|
|
17
|
+
if (!Number.isSafeInteger(budgetMs) || budgetMs < 1 || budgetMs > 5000) throw new Error('intent input invalid-budget');
|
|
18
|
+
const started = performance.now();
|
|
19
|
+
const request = intentInputRequest({ command, values, suppliedOptions, files });
|
|
20
|
+
if (!request) throw new Error('intent input requires an explicit mode');
|
|
21
|
+
const remaining = Math.floor(budgetMs - (performance.now() - started));
|
|
22
|
+
if (remaining < 1) throw new Error('intent input deadline');
|
|
23
|
+
let input;
|
|
24
|
+
if (request.kind === 'document') {
|
|
25
|
+
const admitted = admitIntentDocument({ projectDir, file: request.file });
|
|
26
|
+
input = { kind: 'document', file: admitted.file, hash: admitted.hash, bytes: admitted.identity.size };
|
|
27
|
+
} else {
|
|
28
|
+
input = { kind: 'fix', ...readScopedFixCommitInventory({ projectDir, commit: request.commit, budgetMs: remaining }) };
|
|
29
|
+
}
|
|
30
|
+
const doc = { schemaVersion: 1, tool: { name: 'testguard', version: toolVersion }, purpose: 'authoring-input', verification: 'not-performed', next: 'supply-independent-intent', input };
|
|
31
|
+
let output;
|
|
32
|
+
writeSpecDoc('authoring-input', 'read-only input preview', doc, { publish: bytes => {
|
|
33
|
+
if (Buffer.byteLength(bytes) > 1024 * 1024) throw new Error('intent input output byte limit exceeded');
|
|
34
|
+
output = bytes;
|
|
35
|
+
} });
|
|
36
|
+
if (performance.now() - started >= budgetMs) throw new Error('intent input deadline');
|
|
37
|
+
return freezeOwned({ doc, output });
|
|
38
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { chmodSync, mkdtempSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { authoringLock, persistRecovery, recoveryBase } from '../claims/annotation-placement.mjs';
|
|
5
|
+
import { sha256 } from '../util/hash.mjs';
|
|
6
|
+
import { planDraftAppend } from './plan-append.mjs';
|
|
7
|
+
|
|
8
|
+
function freezeOwned(value) {
|
|
9
|
+
if (value && typeof value === 'object') {
|
|
10
|
+
for (const child of Object.values(value)) freezeOwned(child);
|
|
11
|
+
Object.freeze(value);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Preparation only: current-plan replay and recovery, never draft writes. */
|
|
17
|
+
export function prepareDraftAppend({ request, plan }) {
|
|
18
|
+
const initial = planDraftAppend(request);
|
|
19
|
+
if (!isDeepStrictEqual(initial, plan)) throw new Error('draft append plan changed');
|
|
20
|
+
if (!initial.changed) return freezeOwned({ ready: true, plan: initial, release: () => ({ released: true }), assertOwned: () => {} });
|
|
21
|
+
// All cooperative source/draft authoring in the checkout shares this lock.
|
|
22
|
+
const lock = authoringLock(initial.projectDir);
|
|
23
|
+
let recoveryDir;
|
|
24
|
+
try {
|
|
25
|
+
const fresh = planDraftAppend(request);
|
|
26
|
+
if (!isDeepStrictEqual(fresh, initial)) throw new Error('draft append plan changed under lock');
|
|
27
|
+
lock.assertOwned();
|
|
28
|
+
recoveryDir = mkdtempSync(join(recoveryBase(fresh.projectDir), 'testguard-draft-recovery-'));
|
|
29
|
+
chmodSync(recoveryDir, 0o700);
|
|
30
|
+
persistRecovery(join(recoveryDir, 'draft.original'), Buffer.from(fresh.draft.source, 'utf8'));
|
|
31
|
+
const manifest = {
|
|
32
|
+
state: 'prepared', projectDir: fresh.projectDir, draftPath: fresh.draft.path,
|
|
33
|
+
claimId: fresh.claimId, sourceHash: fresh.draft.hash,
|
|
34
|
+
proposedHash: sha256(Buffer.from(fresh.output, 'utf8')), identity: fresh.draft.identity,
|
|
35
|
+
sources: fresh.sources.map(({ file, hash, identity }) => ({ file, hash, identity })),
|
|
36
|
+
};
|
|
37
|
+
persistRecovery(join(recoveryDir, 'manifest.json'), Buffer.from(JSON.stringify(manifest), 'utf8'));
|
|
38
|
+
lock.assertOwned();
|
|
39
|
+
return freezeOwned({ ready: true, plan: fresh, recoveryDir, release: lock.release, assertOwned: lock.assertOwned });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (recoveryDir) error.recoveryDir = recoveryDir;
|
|
42
|
+
try { error.lockRelease = lock.release(); }
|
|
43
|
+
catch (releaseError) { error.lockRelease = { released: false, reason: 'lock-release-failed', error: releaseError.message }; }
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Token-aware CLI admission; defaults are not explicit user options. */
|
|
2
|
+
export function draftAppendRequest({ command, values, suppliedOptions = [], files = [] }) {
|
|
3
|
+
if (values.into === undefined) return null;
|
|
4
|
+
if (command !== 'scaffold') throw new Error('--into is only valid on scaffold');
|
|
5
|
+
const allowed = new Set(['into', 'claim', 'json', 'help', 'version']);
|
|
6
|
+
const conflict = suppliedOptions.find(name => !allowed.has(name));
|
|
7
|
+
if (conflict || values.out !== undefined || values.claims !== undefined) throw new Error(`--into cannot use --${conflict || (values.out !== undefined ? 'out' : 'claims')}`);
|
|
8
|
+
if (!Array.isArray(values.into) || values.into.length !== 1 || !values.into[0]?.trim() || values.into[0] !== values.into[0].trim()) throw new Error('--into requires exactly one existing draft path');
|
|
9
|
+
if (!Array.isArray(values.claim) || values.claim.length !== 1 || !values.claim[0]?.trim() || values.claim[0].includes(',') || values.claim[0] !== values.claim[0].trim()) throw new Error('--into requires exactly one --claim <existing-ID>');
|
|
10
|
+
if (!files.length) throw new Error('--into requires at least one source file');
|
|
11
|
+
return { draftPath: values.into[0], claimId: values.claim[0], files };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Internal read-only input selection; public CLI exposure requires its report contract. */
|
|
15
|
+
export function intentInputRequest({ command, values, suppliedOptions = [], files = [] }) {
|
|
16
|
+
const modes = ['from-document', 'from-fix'].filter(name => values[name] !== undefined);
|
|
17
|
+
if (!modes.length) return null;
|
|
18
|
+
if ('scaffold' !== command) throw new Error('intent input is only valid on scaffold');
|
|
19
|
+
if (modes.length !== 1) throw new Error('select exactly one input mode');
|
|
20
|
+
const allowed = new Set(['from-document', 'from-fix', 'json', 'help', 'version']);
|
|
21
|
+
const conflict = suppliedOptions.find(name => !allowed.has(name)) ?? ['into', 'claim', 'claims', 'out'].find(name => values[name] !== undefined);
|
|
22
|
+
if (conflict) throw new Error(`intent input cannot use --${conflict}`);
|
|
23
|
+
if (files.length) throw new Error('intent input cannot use positional source files');
|
|
24
|
+
const mode = modes[0], selectors = values[mode];
|
|
25
|
+
if (!Array.isArray(selectors) || selectors.length !== 1 || typeof selectors[0] !== 'string' || !selectors[0] || selectors[0] !== selectors[0].trim() || selectors[0].includes('\0')) throw new Error('intent input requires exactly one nonempty trimmed selector');
|
|
26
|
+
const selector = selectors[0];
|
|
27
|
+
if (Buffer.byteLength(selector) > 4096) throw new Error('intent input selector-byte-limit');
|
|
28
|
+
if (mode === 'from-fix' && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(selector)) throw new Error('intent input requires full-object-id');
|
|
29
|
+
return Object.freeze(mode === 'from-document' ? { kind: 'document', file: selector } : { kind: 'fix', commit: selector });
|
|
30
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { proposalsForLine, functionHead, functionParams } from './producers.mjs';
|
|
4
4
|
import * as py from './producers.python.mjs';
|
|
5
5
|
import { locate } from '../probe/inject.mjs';
|
|
@@ -15,8 +15,6 @@ const NO_FIELD_DROPS = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(__tests__|__fixtures
|
|
|
15
15
|
// change which proposals a JavaScript file gets.
|
|
16
16
|
const NO_FIELD_DROPS_PY = /(^|\/)(test[^/]*\.py|[^/]*_test\.py|conftest\.py)$|(^|\/)(tests?|__fixtures__|fixtures|migrations)\//;
|
|
17
17
|
|
|
18
|
-
const idPart = (s) => s.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toUpperCase();
|
|
19
|
-
|
|
20
18
|
const isPython = (file) => file.endsWith('.py');
|
|
21
19
|
|
|
22
20
|
/**
|
|
@@ -136,10 +134,13 @@ function anchorFor(source, find, offset) {
|
|
|
136
134
|
* with `producedBy: { producer: "derived" }` and TODO statements a human must
|
|
137
135
|
* replace. Never touches the real claims file.
|
|
138
136
|
*/
|
|
139
|
-
export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVersion = '0.0.0' }) {
|
|
140
|
-
|
|
137
|
+
export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVersion = '0.0.0', source: admittedSource, maxProposals }) {
|
|
138
|
+
if (maxProposals !== undefined && (!Number.isSafeInteger(maxProposals) || maxProposals < 0)) throw new Error('maxProposals must be a non-negative safe integer');
|
|
139
|
+
if (admittedSource !== undefined && typeof admittedSource !== 'string') throw new Error('source must be a string');
|
|
140
|
+
// Internal authoring can scan bytes already admitted through a descriptor.
|
|
141
|
+
// Legacy callers still read the file; supplied empty text never falls back.
|
|
142
|
+
const source = admittedSource === undefined ? readFileSync(join(projectDir, file), 'utf8') : admittedSource;
|
|
141
143
|
const lines = source.split('\n');
|
|
142
|
-
const stem = idPart(basename(file, extname(file)));
|
|
143
144
|
const producedBy = { producer: 'derived', by: `testguard scaffold ${toolVersion}` };
|
|
144
145
|
|
|
145
146
|
const groups = new Map(); // key → { id, proposals[] , annotated }
|
|
@@ -150,28 +151,40 @@ export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVe
|
|
|
150
151
|
|
|
151
152
|
const fieldDrops = !(isPython(file) ? NO_FIELD_DROPS_PY : NO_FIELD_DROPS).test(file);
|
|
152
153
|
const scan = isPython(file) ? scanPython : scanJs;
|
|
153
|
-
|
|
154
|
+
let accepted = 0;
|
|
155
|
+
const keep = (fault) => {
|
|
156
|
+
if (!usableProposal(source, fault)) return false;
|
|
157
|
+
if (maxProposals !== undefined && accepted >= maxProposals) throw new Error('scaffold proposal limit exceeded');
|
|
158
|
+
accepted++;
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
const proposals = scan({ source, lines, fieldDrops, keep, anchor: (find, offset) => anchorFor(source, find, offset) });
|
|
154
162
|
|
|
155
163
|
const existing = new Map((existingClaims?.claims ?? []).map((c) => [c.id, c]));
|
|
156
164
|
const usedIds = new Set();
|
|
157
165
|
for (const p of proposals) {
|
|
158
166
|
const key = claimId ? '__all__' : p.annotation ?? p.fn ?? '__file__';
|
|
159
|
-
let id = claimId ?? p.annotation ??
|
|
167
|
+
let id = claimId ?? p.annotation ?? null;
|
|
160
168
|
groupFor(key, id).proposals.push(p);
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
const defendedBy = discoverDefenders(projectDir, file);
|
|
164
172
|
const claims = [];
|
|
173
|
+
const reservedIds = new Set([...existing.keys(), ...[...groups.values()].map(g => g.id).filter(id => id !== null)]);
|
|
174
|
+
let placeholder = 1;
|
|
165
175
|
for (const g of groups.values()) {
|
|
166
176
|
let id = g.id;
|
|
177
|
+
if (id === null) {
|
|
178
|
+
do { id = `TODO-CLAIM-${placeholder++}`; } while (reservedIds.has(id) || usedIds.has(id));
|
|
179
|
+
}
|
|
167
180
|
while (usedIds.has(id)) id += '-2';
|
|
168
181
|
usedIds.add(id);
|
|
169
182
|
const base = existing.get(id);
|
|
170
183
|
const fnLabel = g.proposals[0].fn ? `\`${g.proposals[0].fn}\`` : 'this file';
|
|
171
184
|
const claim = {
|
|
172
185
|
id,
|
|
173
|
-
statement: base?.statement ?? `TODO:
|
|
174
|
-
source: base?.source ?? { kind: '
|
|
186
|
+
statement: base?.statement ?? `TODO: supply intended observable behavior for ${fnLabel} in ${file} from a requirement, ADR, bug or incident: inputs, expected outcome and forbidden outcome (${g.proposals.length} proposed fault${g.proposals.length === 1 ? '' : 's'}; keep or drop each against that intent)`,
|
|
187
|
+
source: base?.source ?? { kind: 'inferred', ref: `testguard scaffold ${file}` },
|
|
175
188
|
severity: base?.severity ?? 'medium',
|
|
176
189
|
producedBy: base?.producedBy ?? producedBy,
|
|
177
190
|
...(base?.defendedBy?.length ? { defendedBy: base.defendedBy } : defendedBy.length ? { defendedBy } : {}),
|