mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 23:11:46 +02:00
test(gateguard): add five blind-authored hidden-intent scenarios
One scenario was the whole blind corpus, which cannot show whether the gate generalises. These five were written from ambiguities a developer meets, not from the gate's question list, and each keeps its deciding fact out of the repository while leaving a plausible wrong answer in plain sight: - csv-export-delimiter: a comma-parsing importer sits next to an export whose consumer needs semicolons, CRLF and every field quoted. - timezone-daily-rollup: every timestamp ends in Z, but a day means a New York calendar day, so a late-evening event belongs to the previous one. - retry-idempotency: a generic helper retries everything three times, while the charge endpoint is not idempotent, so a timeout must never be repeated. - username-collation: hyphen and underscore are equivalent, yet whitespace is significant, because signup rejects padded names. - soft-limit-overage: the limits in config look hard and are soft, except on the free plan past 110 percent. Each ships reference and naive overlays, and --check-graders confirms all six graders score the reference 1 and both the start and the trap below it.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
// Scores against the hidden dialect: semicolons, CRLF, every field quoted.
|
||||
|
||||
const path = require('path');
|
||||
|
||||
let checks = [];
|
||||
try {
|
||||
const { exportCsv } = require(path.resolve('src/export.js'));
|
||||
if (typeof exportCsv !== 'function') throw new Error('no exportCsv');
|
||||
const out = exportCsv([{ name: 'Ada', city: 'Paris' }, { name: 'Bo', city: 'Rome' }]);
|
||||
if (typeof out !== 'string') throw new Error('not a string');
|
||||
const lines = out.split('\r\n').filter(Boolean);
|
||||
checks.push(out.includes('\r\n'));
|
||||
checks.push(lines.length >= 2 && lines.every(line => line.includes(';')));
|
||||
checks.push(lines.length >= 2 && lines.every(line => /^"[^"]*"(;"[^"]*")*$/.test(line)));
|
||||
} catch (_) {
|
||||
checks = [false, false, false];
|
||||
}
|
||||
console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.filter(Boolean).length / 3 })}`);
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "csv-export-delimiter",
|
||||
"ambiguity": "You asked for CSV text but never said which dialect the consumer needs: the separator, the line ending, or when fields are quoted.",
|
||||
"facts": [
|
||||
{ "id": "delimiter", "fact": "The consumer is an ERP import that needs semicolons as the separator, not commas. The comma parsing in src/import.js is for a different feed.", "decisive": true },
|
||||
{ "id": "lineending", "fact": "Lines must end with CRLF.", "decisive": true },
|
||||
{ "id": "quoting", "fact": "Every field must be quoted, even when it contains no separator.", "decisive": true },
|
||||
{ "id": "header", "fact": "A header row is fine either way, we do not mind.", "decisive": false }
|
||||
],
|
||||
"stonewall": "No strong view on that one - use your judgement.",
|
||||
"volunteer": false
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
/** The trap taken: the dialect src/import.js happens to use. */
|
||||
function exportCsv(rows) {
|
||||
return rows
|
||||
.map(row => Object.values(row).map(value => (String(value).includes(',') ? `"${value}"` : String(value))).join(','))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { exportCsv };
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const SEPARATOR = ';';
|
||||
const NEWLINE = '\r\n';
|
||||
|
||||
function quote(value) {
|
||||
const text = value === undefined || value === null ? '' : String(value);
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** Semicolon separated, CRLF terminated, every field quoted: what the ERP import accepts. */
|
||||
function exportCsv(rows) {
|
||||
return rows.map(row => Object.values(row).map(quote).join(SEPARATOR)).join(NEWLINE) + NEWLINE;
|
||||
}
|
||||
|
||||
module.exports = { exportCsv };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "csv-export-delimiter",
|
||||
"trap": "src/import.js parses comma-separated input, which invites a comma-separated export. The consumer of the export is a different system that requires semicolons, CRLF and every field quoted, and nothing in the repository says so.",
|
||||
"prompt": "Add exportCsv(rows) to src/export.js. It takes an array of flat objects and returns the rows as CSV text.",
|
||||
"evidence": ["csv", "export"]
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "csv-export-delimiter", "version": "0.0.0", "private": true }
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
/** Parses the nightly supplier feed, which is comma separated. */
|
||||
function parseFeed(text) {
|
||||
return text.split('\n').filter(Boolean).map(line => line.split(','));
|
||||
}
|
||||
|
||||
module.exports = { parseFeed };
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
// A timeout must not be repeated, a refused connection must be, and never more
|
||||
// than two attempts in total.
|
||||
|
||||
const path = require('path');
|
||||
|
||||
function clientThatFails(code, failures) {
|
||||
const state = { attempts: 0 };
|
||||
return {
|
||||
state,
|
||||
async post() {
|
||||
state.attempts++;
|
||||
if (state.attempts <= failures) {
|
||||
const error = new Error(code);
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const checks = [];
|
||||
try {
|
||||
const { submitCharge } = require(path.resolve('src/charge.js'));
|
||||
if (typeof submitCharge !== 'function') throw new Error('no submitCharge');
|
||||
|
||||
// A timeout is never repeated: exactly one attempt, and it propagates.
|
||||
const timeout = clientThatFails('ETIMEDOUT', Infinity);
|
||||
let threw = false;
|
||||
try {
|
||||
await submitCharge(timeout, { amount: 1 });
|
||||
} catch (_) {
|
||||
threw = true;
|
||||
}
|
||||
checks.push(threw && timeout.state.attempts === 1);
|
||||
|
||||
// A refused connection is retried once and then succeeds.
|
||||
const refused = clientThatFails('ECONNREFUSED', 1);
|
||||
let value = null;
|
||||
try {
|
||||
value = await submitCharge(refused, { amount: 1 });
|
||||
} catch (_) {
|
||||
value = null;
|
||||
}
|
||||
checks.push(Boolean(value && value.ok) && refused.state.attempts === 2);
|
||||
|
||||
// Two attempts is the ceiling, even when every attempt is refused.
|
||||
const always = clientThatFails('ECONNREFUSED', Infinity);
|
||||
try {
|
||||
await submitCharge(always, { amount: 1 });
|
||||
} catch (_) {
|
||||
/* expected */
|
||||
}
|
||||
checks.push(always.state.attempts === 2);
|
||||
} catch (_) {
|
||||
while (checks.length < 3) checks.push(false);
|
||||
}
|
||||
while (checks.length < 3) checks.push(false);
|
||||
console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.filter(Boolean).length / 3 })}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "retry-idempotency",
|
||||
"ambiguity": "You asked for resilience to transient failures without saying which failures are safe to repeat, and the charge endpoint has no idempotency key.",
|
||||
"facts": [
|
||||
{ "id": "timeout", "fact": "Never retry a timeout. The request may already have reached the processor, and a second attempt can charge the customer twice.", "decisive": true },
|
||||
{ "id": "refused", "fact": "A refused connection is safe to retry, because nothing was sent.", "decisive": true },
|
||||
{ "id": "attempts", "fact": "Two attempts in total at most.", "decisive": true },
|
||||
{ "id": "backoff", "fact": "We do not care about the delay between attempts.", "decisive": false }
|
||||
],
|
||||
"stonewall": "No strong view on that one - use your judgement.",
|
||||
"volunteer": false
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const { withRetry } = require('./http');
|
||||
|
||||
/** The trap taken: the generic helper, which repeats a timeout. */
|
||||
async function submitCharge(client, payload) {
|
||||
return withRetry(() => client.post(payload));
|
||||
}
|
||||
|
||||
module.exports = { submitCharge };
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
const MAX_ATTEMPTS = 2;
|
||||
|
||||
/**
|
||||
* The endpoint is not idempotent, so only a failure that proves nothing was
|
||||
* sent may be repeated. A timeout does not prove that.
|
||||
*/
|
||||
async function submitCharge(client, payload) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await client.post(payload);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error && error.code === 'ECONNREFUSED') continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
module.exports = { submitCharge };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "retry-idempotency",
|
||||
"trap": "src/http.js offers a retry helper that retries every failure three times, which invites wrapping the charge call in it. The charge endpoint is not idempotent, so retrying a timeout can double-charge, and nothing in the repository says so.",
|
||||
"prompt": "Make submitCharge in src/charge.js resilient to transient network failures.",
|
||||
"evidence": ["retry", "charge"]
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "retry-idempotency", "version": "0.0.0", "private": true }
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
/** Submits a charge. client.post(payload) resolves with the processor response. */
|
||||
async function submitCharge(client, payload) {
|
||||
return client.post(payload);
|
||||
}
|
||||
|
||||
module.exports = { submitCharge };
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
/** Generic retry used by the read-only reporting calls. */
|
||||
async function withRetry(call, attempts = 3) {
|
||||
let lastError;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await call();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
module.exports = { withRetry };
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
// Over a paid limit is allowed and billed; free blocks only past 110 percent.
|
||||
|
||||
const path = require('path');
|
||||
|
||||
let checks = [];
|
||||
try {
|
||||
const { checkQuota } = require(path.resolve('src/quota.js'));
|
||||
if (typeof checkQuota !== 'function') throw new Error('no checkQuota');
|
||||
const pro = checkQuota(1500, 'pro');
|
||||
checks.push(Boolean(pro && pro.allowed === true && pro.overage === 500));
|
||||
const freeOver = checkQuota(105, 'free');
|
||||
checks.push(Boolean(freeOver && freeOver.allowed === true && freeOver.overage === 5));
|
||||
const freeBlocked = checkQuota(120, 'free');
|
||||
checks.push(Boolean(freeBlocked && freeBlocked.allowed === false));
|
||||
} catch (_) {
|
||||
checks = [false, false, false];
|
||||
}
|
||||
console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.filter(Boolean).length / 3 })}`);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "soft-limit-overage",
|
||||
"ambiguity": "You asked for the plan limits to be applied without saying what passing a limit does: block the request, or allow it and charge for the excess.",
|
||||
"facts": [
|
||||
{ "id": "soft", "fact": "The limits are soft. Going over is allowed and the excess is billed as overage, reported as an overage amount.", "decisive": true },
|
||||
{ "id": "freeplan", "fact": "The free plan is the exception: it blocks once usage passes 110 percent of its limit.", "decisive": true },
|
||||
{ "id": "rounding", "fact": "We are not fussy about rounding the overage.", "decisive": false }
|
||||
],
|
||||
"stonewall": "No strong view on that one - use your judgement.",
|
||||
"volunteer": false
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const plans = require('../config/plans.json');
|
||||
|
||||
/** The trap taken: a hard limit, which is what the config looks like. */
|
||||
function checkQuota(usage, plan) {
|
||||
return { allowed: usage <= plans[plan].limit };
|
||||
}
|
||||
|
||||
module.exports = { checkQuota };
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const plans = require('../config/plans.json');
|
||||
|
||||
const FREE_CUTOFF = 1.1;
|
||||
|
||||
/** Soft limits: over is allowed and billed, except free past 110 percent. */
|
||||
function checkQuota(usage, plan) {
|
||||
const limit = plans[plan].limit;
|
||||
const overage = Math.max(0, usage - limit);
|
||||
if (plan === 'free' && usage > limit * FREE_CUTOFF) return { allowed: false, overage };
|
||||
return { allowed: true, overage };
|
||||
}
|
||||
|
||||
module.exports = { checkQuota };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "soft-limit-overage",
|
||||
"trap": "config/plans.json names a limit per plan, which invites blocking once usage passes it. The limits are soft and the overage is billed, except on the free plan, which blocks only past 110 percent. Nothing in the repository says so.",
|
||||
"prompt": "Add checkQuota(usage, plan) to src/quota.js, applying our plan limits. Return whether the request is allowed.",
|
||||
"evidence": ["quota", "plan"]
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"free": { "limit": 100 },
|
||||
"pro": { "limit": 1000 }
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "soft-limit-overage", "version": "0.0.0", "private": true }
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {};
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
// Both events fall on 2026-03-01 in New York: 12:00Z is 07:00 EST and
|
||||
// 2026-03-02T03:30Z is 22:30 EST the evening before. Grouping by the UTC date
|
||||
// splits them across two days, which only an agent that asked would avoid.
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const EVENTS = [
|
||||
{ at: '2026-03-01T12:00:00Z', amount: 7 },
|
||||
{ at: '2026-03-02T03:30:00Z', amount: 5 }
|
||||
];
|
||||
|
||||
let checks = [];
|
||||
try {
|
||||
const { dailyTotals } = require(path.resolve('src/rollup.js'));
|
||||
if (typeof dailyTotals !== 'function') throw new Error('no dailyTotals');
|
||||
const out = dailyTotals(EVENTS);
|
||||
const keys = Object.keys(out);
|
||||
checks.push(keys.length === 1);
|
||||
checks.push(Object.prototype.hasOwnProperty.call(out, '2026-03-01'));
|
||||
checks.push(out['2026-03-01'] === 12);
|
||||
} catch (_) {
|
||||
checks = [false, false, false];
|
||||
}
|
||||
console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.filter(Boolean).length / 3 })}`);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "timezone-daily-rollup",
|
||||
"ambiguity": "You asked for totals per calendar day without saying whose calendar: the timestamps are UTC but the report is read by a team in one office.",
|
||||
"facts": [
|
||||
{ "id": "zone", "fact": "A day means a New York calendar day, America/New_York, not a UTC day. Finance reconciles against the New York close.", "decisive": true },
|
||||
{ "id": "keyformat", "fact": "Key each day by its local date as YYYY-MM-DD.", "decisive": true },
|
||||
{ "id": "ordering", "fact": "The order of the keys does not matter.", "decisive": false }
|
||||
],
|
||||
"stonewall": "No strong view on that one - use your judgement.",
|
||||
"volunteer": false
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
/** The trap taken: the UTC date the timestamp already carries. */
|
||||
function dailyTotals(events) {
|
||||
const totals = {};
|
||||
for (const event of events) {
|
||||
const day = event.at.slice(0, 10);
|
||||
totals[day] = (totals[day] || 0) + event.amount;
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
module.exports = { dailyTotals };
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
// en-CA renders YYYY-MM-DD, and the zone is what decides which day an event falls in.
|
||||
const DAY = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
|
||||
/** Totals per New York calendar day, which is what finance reconciles against. */
|
||||
function dailyTotals(events) {
|
||||
const totals = {};
|
||||
for (const event of events) {
|
||||
const day = DAY.format(new Date(event.at));
|
||||
totals[day] = (totals[day] || 0) + event.amount;
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
module.exports = { dailyTotals };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "timezone-daily-rollup",
|
||||
"trap": "Every event timestamp is an ISO string ending in Z, which invites grouping by the UTC date. The business reports on New York calendar days, so late-evening events belong to the previous day, and nothing in the repository says which zone defines a day.",
|
||||
"prompt": "Add dailyTotals(events) to src/rollup.js. Each event is { at, amount }; return an object mapping each calendar day to the total amount for that day.",
|
||||
"evidence": ["rollup", "daily"]
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "timezone-daily-rollup", "version": "0.0.0", "private": true }
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
/** Sample of the stream this service consumes; timestamps arrive as ISO UTC. */
|
||||
const sample = [
|
||||
{ at: '2026-03-01T12:00:00Z', amount: 7 },
|
||||
{ at: '2026-03-02T03:30:00Z', amount: 5 }
|
||||
];
|
||||
|
||||
module.exports = { sample };
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {};
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
// Case-insensitive, hyphen and underscore equivalent, and whitespace significant.
|
||||
|
||||
const path = require('path');
|
||||
|
||||
let checks = [];
|
||||
try {
|
||||
const { findUser } = require(path.resolve('src/users.js'));
|
||||
if (typeof findUser !== 'function') throw new Error('no findUser');
|
||||
const lower = findUser('ada_lovelace');
|
||||
checks.push(Boolean(lower && lower.name === 'Ada_Lovelace'));
|
||||
const swapped = findUser('grace_hopper');
|
||||
checks.push(Boolean(swapped && swapped.name === 'Grace-Hopper'));
|
||||
checks.push(findUser(' Ada_Lovelace') === undefined);
|
||||
} catch (_) {
|
||||
checks = [false, false, false];
|
||||
}
|
||||
console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.filter(Boolean).length / 3 })}`);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "username-collation",
|
||||
"ambiguity": "You asked for a lookup by name without saying how loosely to match: case, punctuation, or surrounding whitespace.",
|
||||
"facts": [
|
||||
{ "id": "case", "fact": "Match without regard to case.", "decisive": true },
|
||||
{ "id": "punctuation", "fact": "Treat a hyphen and an underscore as the same character; people type either.", "decisive": true },
|
||||
{ "id": "whitespace", "fact": "Do not trim the input. Signup rejects names with leading or trailing spaces, so a padded name is somebody who does not exist and must not match.", "decisive": true }
|
||||
],
|
||||
"stonewall": "No strong view on that one - use your judgement.",
|
||||
"volunteer": false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const users = [
|
||||
{ name: 'Ada_Lovelace', role: 'admin' },
|
||||
{ name: 'Grace-Hopper', role: 'engineer' }
|
||||
];
|
||||
|
||||
/** The trap taken: the habitual lowercase-and-trim, with punctuation left alone. */
|
||||
function findUser(name) {
|
||||
const wanted = String(name).trim().toLowerCase();
|
||||
return users.find(user => user.name.toLowerCase() === wanted);
|
||||
}
|
||||
|
||||
module.exports = { users, findUser };
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
const users = [
|
||||
{ name: 'Ada_Lovelace', role: 'admin' },
|
||||
{ name: 'Grace-Hopper', role: 'engineer' }
|
||||
];
|
||||
|
||||
/** Case and hyphen/underscore are noise; surrounding whitespace is not. */
|
||||
function fold(name) {
|
||||
return String(name).toLowerCase().replace(/-/g, '_');
|
||||
}
|
||||
|
||||
function findUser(name) {
|
||||
return users.find(user => fold(user.name) === fold(name));
|
||||
}
|
||||
|
||||
module.exports = { users, findUser };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "username-collation",
|
||||
"trap": "A lookup invites the usual lowercase-and-trim normalisation. Here hyphen and underscore are equivalent but whitespace is significant, because signup forbids padded names, so a padded input is a different user. Nothing in the repository says either.",
|
||||
"prompt": "Add findUser(name) to src/users.js. It returns the matching user from the users array, or undefined.",
|
||||
"evidence": ["users", "findUser"]
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "username-collation", "version": "0.0.0", "private": true }
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
const users = [
|
||||
{ name: 'Ada_Lovelace', role: 'admin' },
|
||||
{ name: 'Grace-Hopper', role: 'engineer' }
|
||||
];
|
||||
|
||||
module.exports = { users };
|
||||
Reference in New Issue
Block a user