Let provenance comments move between the files of one submission

A definition moving to its owner unit keeps its // STATIC: line. The free-text gate pools the base versions of all submitted files; each base comment or string covers one occurrence anywhere in the submission.
This commit is contained in:
ragnarok-clanker 2026-10-26 19:33:31 +01:00
commit 95fd65997f
3 changed files with 35 additions and 12 deletions

View file

@ -4,7 +4,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { lintSubmission } from '../shared/lint.js'; import { lintSubmission } from '../shared/lint.js';
import { freeTextErrors } from '../shared/freetext.js'; import { freeTextErrorsAcross } from '../shared/freetext.js';
import { checkUnits } from '../shared/check.js'; import { checkUnits } from '../shared/check.js';
import { reconcile } from '../shared/reconcile.js'; import { reconcile } from '../shared/reconcile.js';
import { STATUS, sameOriginal } from '../shared/compare.js'; import { STATUS, sameOriginal } from '../shared/compare.js';
@ -225,16 +225,14 @@ export class Verifier {
/** /**
* Comments and strings a submission adds that could carry instructions to other agents: only * Comments and strings a submission adds that could carry instructions to other agents: only
* annotations and markers, and strings of the original executable, are allowed (freetext.js). * annotations and markers, and strings of the original executable, are allowed (freetext.js).
* What the submitted files held on the base may move between them (a definition moving to its
* owner keeps its provenance), but every such line counts once.
*/ */
freeTextGate(files, baseCommit) { freeTextGate(files, baseCommit) {
const exe = this.getPe().buf; const exe = this.getPe().buf;
const inOriginal = (bytes) => exe.indexOf(bytes) !== -1; const inOriginal = (bytes) => exe.indexOf(bytes) !== -1;
const errors = []; const pooled = Object.keys(files).map((f) => this.repo.show(baseCommit, f)).filter((t) => t !== null).join('\n');
for (const [file, text] of Object.entries(files)) { return freeTextErrorsAcross(files, pooled, inOriginal);
if (typeof text !== 'string') continue;
for (const e of freeTextErrors(text, this.repo.show(baseCommit, file), inOriginal)) errors.push({ file, ...e });
}
return errors;
} }
/** /**

View file

@ -160,18 +160,35 @@ function encode(s, wide) {
* @returns {{line: number, message: string}[]} the messages never repeat the text itself * @returns {{line: number, message: string}[]} the messages never repeat the text itself
*/ */
export function freeTextErrors(text, baseText, inOriginal) { export function freeTextErrors(text, baseText, inOriginal) {
const now = scanText(text); return checkText(scanText(text), baseText === null ? { comments: [], strings: [] } : scanText(baseText), inOriginal);
const base = baseText === null ? { comments: [], strings: [] } : scanText(baseText); }
const errors = [];
const baseComments = counts(base.comments.map((c) => c.text.trim())); /**
* The same check for a whole submission against what all its files held together on the base:
* comments and strings may move from one submitted file to another, each base occurrence once.
* @returns {{file: string, line: number, message: string}[]}
*/
export function freeTextErrorsAcross(files, pooledBase, inOriginal) {
const base = scanText(pooledBase);
const pool = { comments: counts(base.comments.map((c) => c.text.trim())), strings: counts(base.strings.map(stringKey)) };
const errors = [];
for (const [file, text] of Object.entries(files)) {
if (typeof text !== 'string') continue;
for (const e of checkText(scanText(text), pool, inOriginal)) errors.push({ file, ...e });
}
return errors;
}
function checkText(now, base, inOriginal) {
const errors = [];
const baseComments = base.comments instanceof Map ? base.comments : counts(base.comments.map((c) => c.text.trim()));
for (const c of now.comments) { for (const c of now.comments) {
const t = c.text.trim(); const t = c.text.trim();
if (take(baseComments, t) || allowedComment(t)) continue; if (take(baseComments, t) || allowedComment(t)) continue;
errors.push({ line: c.line, message: 'comments are not allowed in submissions (only annotations like // FUNCTION: fn_00001 and offset markers like /* 0010 */); remove it' }); errors.push({ line: c.line, message: 'comments are not allowed in submissions (only annotations like // FUNCTION: fn_00001 and offset markers like /* 0010 */); remove it' });
} }
const baseStrings = counts(base.strings.map(stringKey)); const baseStrings = base.strings instanceof Map ? base.strings : counts(base.strings.map(stringKey));
for (const s of now.strings) { for (const s of now.strings) {
if (take(baseStrings, stringKey(s))) continue; if (take(baseStrings, stringKey(s))) continue;
if (s.include !== null) { if (s.include !== null) {

View file

@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { scanText, freeTextErrors, allowedComment } from '../src/shared/freetext.js'; import { scanText, freeTextErrors, freeTextErrorsAcross, allowedComment } from '../src/shared/freetext.js';
// Stands in for the original executable's bytes. // Stands in for the original executable's bytes.
const original = Buffer.concat([Buffer.from('Interface\\GlueXML\0', 'latin1'), Buffer.from('hello world\0%d\0', 'latin1'), Buffer.from('Wide\0', 'utf16le')]); const original = Buffer.concat([Buffer.from('Interface\\GlueXML\0', 'latin1'), Buffer.from('hello world\0%d\0', 'latin1'), Buffer.from('Wide\0', 'utf16le')]);
@ -51,6 +51,14 @@ test('comments and strings already on main stay allowed', () => {
assert.equal(freeTextErrors(base + '// keep me\n', base, inOriginal).length, 1); assert.equal(freeTextErrors(base + '// keep me\n', base, inOriginal).length, 1);
}); });
test('a submission may move a comment from one of its files to another, once', () => {
const base = '// keep me\nextern int g;\n';
const files = { 'a.cpp': 'extern int g;\n', 'b.cpp': '// keep me\nint g;\n' };
assert.deepEqual(freeTextErrorsAcross(files, base, inOriginal), [], 'moved with its definition');
const twice = { 'a.cpp': base, 'b.cpp': '// keep me\nint g;\n' };
assert.equal(freeTextErrorsAcross(twice, base, inOriginal).length, 1, 'one base line covers one copy');
});
test('pieces of a concatenated literal may each come from the original', () => { test('pieces of a concatenated literal may each come from the original', () => {
assert.deepEqual(freeTextErrors('const char* s = "%d" "hello world";\n', null, inOriginal), []); assert.deepEqual(freeTextErrors('const char* s = "%d" "hello world";\n', null, inOriginal), []);
assert.equal(freeTextErrors('const char* s = "%d" "made up";\n', null, inOriginal).length, 1); assert.equal(freeTextErrors('const char* s = "%d" "made up";\n', null, inOriginal).length, 1);