From b55b592a82d6f6f53c792ae609e507a767d17aec Mon Sep 17 00:00:00 2001 From: Saty <5752427+SatyPardus@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:29:30 +0200 Subject: [PATCH] Submissions may not declare functions that are defined nowhere The server compiles units one by one and never links, so a call to a function declared only by its prototype (an invented helper, or a member declared in a header and never written) compiled and could merge; main would not link. A newly declared function at file or class scope must be defined in the submission, exist elsewhere in the repository, or be a known function of the original (by name or fn_ ID). Co-Authored-By: Claude Opus 5.5 --- src/server/undefined.js | 111 ++++++++++++++++++++++++++++++++++++++++ src/server/verify.js | 23 +++++++++ test/undefined.test.js | 32 ++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 src/server/undefined.js create mode 100644 test/undefined.test.js diff --git a/src/server/undefined.js b/src/server/undefined.js new file mode 100644 index 0000000..4f46a38 --- /dev/null +++ b/src/server/undefined.js @@ -0,0 +1,111 @@ +// Functions a submission declares but nobody defines. The server compiles units one by one and +// compares their code; it never links. A call to a function declared only by its prototype +// compiles fine (an external reference) and no comparison notices, so an agent could "port" a +// function by calling helpers that exist nowhere (CAsyncObjectCancel, LocaleStringToIndex), and +// main would no longer link. A newly declared function must be defined in the submission, exist +// elsewhere in the repository, or be a known function of the original (the symbol database). + +const NOT_A_TYPE = new Set(['return', 'delete', 'throw', 'else', 'case', 'goto', 'sizeof', 'new', 'do', 'if', 'while', 'for', 'switch', 'co_return']); + +// A file-scope or class-scope prototype on one line: `ret name(params) [const];` +const PROTOTYPE = /^\s*((?:[A-Za-z_][\w:<>,*&\s]*?)[\s*&])([A-Za-z_]\w*)\s*\(([^;{}()]*(?:\([^()]*\)[^;{}()]*)*)\)\s*(?:const\s*)?(?:=\s*0\s*)?;\s*(?:\/\/.*)?$/; + +function lineSet(text) { + const m = new Map(); + for (const l of String(text ?? '').replace(/\r\n/g, '\n').split('\n')) m.set(l, (m.get(l) ?? 0) + 1); + return m; +} + +/** + * For each line, whether it is at a scope where a prototype declares a function: file, namespace, + * extern "C" or a class body. Inside a function body `Type name(args);` is a variable. + */ +function declarationScopes(lines) { + const out = []; + const stack = []; // true for scopes that hold declarations + let pendingDecl = false; // a class/struct/union/namespace/extern head waits for its brace + let block = false; + for (const raw of lines) { + let line = raw; + if (block) { + const e = line.indexOf('*/'); + line = e < 0 ? '' : line.slice(e + 2); + if (e >= 0) block = false; + } + line = line.replace(/\/\*.*?\*\//g, ' ').replace(/\/\/.*$/, '').replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'(?:[^'\\]|\\.)*'/g, "''"); + const open = line.indexOf('/*'); + if (open >= 0) { + block = true; + line = line.slice(0, open); + } + out.push(stack.every(Boolean)); + if (/\b(?:class|struct|union|namespace)\b[^;()]*$/.test(line.replace(/template\s*<[^>]*>/g, '')) || /\bextern\s+"C"/.test(raw)) pendingDecl = true; + for (const c of line) { + if (c === '{') { + stack.push(pendingDecl); + pendingDecl = false; + } else if (c === '}') stack.pop(); + else if (c === ';') pendingDecl = false; + } + } + return out; +} + +/** Names of functions declared on lines the submission added (not in the base version). */ +export function newPrototypes(after, before) { + const old = lineSet(before); + const seen = new Map(); + const out = new Set(); + const lines = String(after ?? '').replace(/\r\n/g, '\n').split('\n'); + const declScope = declarationScopes(lines); + for (const [i, line] of lines.entries()) { + seen.set(line, (seen.get(line) ?? 0) + 1); + if (seen.get(line) <= (old.get(line) ?? 0) || !declScope[i]) continue; + const m = PROTOTYPE.exec(line); + if (!m) continue; + const first = m[1].trim().split(/\s+/)[0]; + if (NOT_A_TYPE.has(first) || /=/.test(m[1])) continue; + // A literal in the parentheses makes it an object constructed with arguments: CImVector c(0xFF, ...). + if (/(?:^|[(,\s])(?:-?\d|'|")/.test(m[3])) continue; + out.add(m[2]); + } + return [...out]; +} + +/** Whether `text` has a definition (a body) of a function called `name`. */ +export function definesFunction(text, name) { + const s = String(text ?? ''); + const re = new RegExp(`\\b${name}\\s*\\(`, 'g'); + let m; + while ((m = re.exec(s))) { + let depth = 0; + let i = m.index + m[0].length - 1; + for (; i < s.length; i++) { + if (s[i] === '(') depth++; + else if (s[i] === ')' && --depth === 0) break; + } + const rest = s.slice(i + 1, i + 200); + if (/^\s*(?:const\s*)?(?:noexcept\s*)?(?::[^;{]*)?\{/.test(rest)) return true; + } + return false; +} + +/** + * Functions the submission newly declares that are defined nowhere. `existsElsewhere(name)`: + * the name occurs in the repository outside the submitted files; `known(name)`: a function of + * the original has that name. + */ +export function undefinedDeclarations(files, { before, existsElsewhere, known }) { + const declared = new Set(); + for (const [file, text] of Object.entries(files)) { + if (typeof text !== 'string') continue; + for (const n of newPrototypes(text, before(file))) declared.add(n); + } + const out = []; + for (const name of declared) { + if (Object.values(files).some((t) => typeof t === 'string' && definesFunction(t, name))) continue; + if (known(name) || existsElsewhere(name)) continue; + out.push(name); + } + return out; +} diff --git a/src/server/verify.js b/src/server/verify.js index 24f06b6..6347391 100644 --- a/src/server/verify.js +++ b/src/server/verify.js @@ -10,6 +10,7 @@ import { reconcile } from '../shared/reconcile.js'; import { STATUS } from '../shared/compare.js'; import { listSources, isUnit } from '../shared/project.js'; import { changedNames, unitsMentioning } from './affected.js'; +import { undefinedDeclarations } from './undefined.js'; import { className } from './grouping.js'; import { now } from './db.js'; import { returnBatch, batchAttempts, settleBatch } from './batches.js'; @@ -220,6 +221,21 @@ export class Verifier { return errors; } + /** Functions the submission declares that nothing defines (undefined.js). */ + undefinedGate(files, baseCommit) { + const submitted = Object.keys(files); + // A function of the original: by name, or by its stable ID (agents name unnamed ones fn_01234). + const known = (name) => !!this.store.get('SELECT 1 FROM symbols WHERE id = ? OR name = ? OR name LIKE ? LIMIT 1', name, name, `%::${name}`); + const existsElsewhere = (name) => { + try { + return this.repo.git(['grep', '-l', '-w', '-F', '-e', name, 'HEAD', '--', '.', ...submitted.map((f) => `:(exclude)${f}`)]).trim().length > 0; + } catch { + return false; // git grep exits 1 when nothing matches + } + }; + return undefinedDeclarations(files, { before: (f) => this.repo.show(baseCommit, f), existsElsewhere, known }); + } + // What a merge needs from the verification's check (without listings and byte diffs). rememberCheck(batchId, { check, rec, changedHeaders }) { const compact = { @@ -396,6 +412,13 @@ export class Verifier { if (!this.repo.isAncestor(batch.base_commit)) return finishAll('rejected', { summary: `base commit ${batch.base_commit} is not part of main; sync and resubmit` }); const textErrors = this.freeTextGate(submitted, batch.base_commit); if (textErrors.length) return finishAll('rejected', { summary: 'rejected: the submission adds comments or strings that are not allowed (see lintErrors)', lintErrors: textErrors }); + const undefinedNames = this.undefinedGate(submitted, batch.base_commit); + if (undefinedNames.length) { + return finishAll('needs_cleanup', { + summary: `declares functions that are defined nowhere: ${undefinedNames.join(', ')}. Main is linked as a whole: call the real function of the original (td_lookup_symbol; td_new_symbol for one not in the database yet) and port it too if it has no code yet, or write the logic inline`, + undefinedFunctions: undefinedNames, + }); + } const merged = this.repo.mergeOntoHead(batch.base_commit, submitted); if (merged.conflicts.length) return finishAll('needs_cleanup', { summary: 'your change conflicts with newer work on main; sync, merge and resubmit', conflicts: merged.conflicts }); diff --git a/test/undefined.test.js b/test/undefined.test.js new file mode 100644 index 0000000..f1d9842 --- /dev/null +++ b/test/undefined.test.js @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { newPrototypes, definesFunction, undefinedDeclarations } from '../src/server/undefined.js'; + +test('functions a submission declares but nobody defines are found', () => { + const before = 'static void LoadAddOnMetadata(UIADDON* addon, const char* line) {\n}\n'; + const after = [ + 'int32_t LocaleStringToIndex(const char* locale);', + 'int32_t LocaleIndexToClientLocale(int32_t index);', + 'void Helper(int a);', + 'void Known(int a);', + 'void Elsewhere(int a);', + 'static void LoadAddOnMetadata(UIADDON* addon, const char* line) {', + ' return Compute(addon);', + ' x = Other(1);', + ' ScopedGuard resultGuard(addon);', + ' std::vector timeslots(16);', + '}', + 'static const CImVector s_shipMarkerColor(0xFF, 0xFF, 0xFF, 0xFF);', + 'class Widget {', + ' void Member(int a);', + '};', + 'void Helper(int a) {', + '}', + ].join('\n'); + assert.deepEqual(newPrototypes(after, before).sort(), ['Elsewhere', 'Helper', 'Known', 'LocaleIndexToClientLocale', 'LocaleStringToIndex', 'Member'], 'locals constructed with arguments are not prototypes'); + assert.ok(definesFunction(after, 'Helper')); + assert.ok(!definesFunction(after, 'LocaleStringToIndex')); + const missing = undefinedDeclarations({ 'a.cpp': after }, { before: () => before, known: (n) => n === 'Known', existsElsewhere: (n) => n === 'Elsewhere' }); + assert.deepEqual(missing.sort(), ['LocaleIndexToClientLocale', 'LocaleStringToIndex', 'Member']); + assert.deepEqual(undefinedDeclarations({ 'a.cpp': before }, { before: () => before, known: () => false, existsElsewhere: () => false }), [], 'nothing new, nothing to report'); +});