@ttctl/core
Advanced tools
@@ -30,6 +30,10 @@ /** | ||
| /** | ||
| * A single survey question. `inputType` describes how the answer is | ||
| * collected (e.g. a rating scale, free text); `answers` enumerates the | ||
| * selectable options (empty for free-text and checkbox questions). Downstream | ||
| * `surveys submit` / `surveys feedback` consume `id` + `inputType` + | ||
| * A single survey question. `inputType` is a `SurveyInputTypeEnum` value | ||
| * describing how the answer is collected (a rating scale, free text, …); | ||
| * `answers` enumerates the selectable options. | ||
| * | ||
| * `answers` emptiness does NOT classify the question: an `OPEN_TEXT` question | ||
| * on an `ENGAGEMENT_ENDED` survey arrives carrying one sentinel option, while | ||
| * the same type on `MID_ENGAGEMENT` arrives with none (#877). `inputType` is | ||
| * the authority. Downstream `surveys submit` consumes `id` + `inputType` + | ||
| * `answers[].value` to build a valid response. | ||
@@ -79,5 +83,5 @@ */ | ||
| * `id` is the selected {@link SurveyAnswerOption} id for a multiple-choice | ||
| * question and `null` for free-text; `value` is the option's `value` | ||
| * (multiple-choice) or the free-text answer. Matches the `SurveyAnswerInput` | ||
| * wire shape verified by a live round-trip (2026-05-29). | ||
| * question and `null` for every other treatment; `value` is the option's | ||
| * `value` (multiple-choice) or the caller's answer. Matches the | ||
| * `SurveyAnswerInput` wire shape verified by a live round-trip (2026-05-29). | ||
| */ | ||
@@ -90,6 +94,10 @@ export interface SurveyAnswerInput { | ||
| /** | ||
| * A caller-supplied answer before resolution. For a multiple-choice | ||
| * question the `value` is matched against the question's answer options to | ||
| * recover the option id; a free-text value is sent verbatim and a checkbox | ||
| * takes `"true"`/`"false"`. {@link buildSurveyAnswers} performs the resolution. | ||
| * A caller-supplied answer before resolution. For a multiple-choice question | ||
| * (`RADIO_BUTTONS` / `RATING` / `SLIDER`) the `value` is matched against the | ||
| * question's answer options to recover the option id; a free-text | ||
| * (`OPEN_TEXT`) value is sent verbatim, a `CHECKBOX` takes `"true"`/`"false"`, | ||
| * and a `PROPOSED_ENGAGEMENT_END_DATE` sends the caller's date (or the | ||
| * `"I do not know"` sentinel) with no option id. The question's `inputType` — | ||
| * not its `answers` length — selects the treatment. | ||
| * {@link buildSurveyAnswers} performs the resolution. | ||
| */ | ||
@@ -96,0 +104,0 @@ export interface RawSurveyAnswer { |
@@ -139,21 +139,58 @@ // SPDX-License-Identifier: AGPL-3.0-only | ||
| }`; | ||
| // --------------------------------------------------------------------- | ||
| // submit — answer resolution | ||
| // --------------------------------------------------------------------- | ||
| /** | ||
| * `SurveyQuestion.inputType` for a boolean checkbox question — carries no | ||
| * answer options yet requires a stringified-boolean value (e.g. the mandatory | ||
| * "This interview didn't occur." question on an `INTERVIEW_ENDED` survey). | ||
| * Exhaustive `inputType` → answer-shape classification, keyed on the GENERATED | ||
| * `SurveyInputTypeEnum` so a member added by `pnpm codegen` stops this map | ||
| * compiling until it is classified, instead of falling through to | ||
| * option-matching. | ||
| * | ||
| * That gate catches a REGENERATED schema, not a new value on the wire — | ||
| * `inputType` is `String!` in the SDL and the enum is an orphan no field | ||
| * references — which is why unrecognized values route to `unmodelled`. | ||
| */ | ||
| const CHECKBOX_INPUT_TYPE = "CHECKBOX"; | ||
| const ANSWER_SHAPE_BY_INPUT_TYPE = { | ||
| CHECKBOX: "boolean", | ||
| OPEN_TEXT: "free-text", | ||
| PROPOSED_ENGAGEMENT_END_DATE: "date", | ||
| RADIO_BUTTONS: "option", | ||
| RATING: "option", | ||
| SLIDER: "option", | ||
| }; | ||
| /** | ||
| * Classify a question's declared `inputType`. Matching is EXACT: the two values | ||
| * grounded in live wire evidence are `CHECKBOX` (#754) and `OPEN_TEXT` (#877), | ||
| * both upper-case, and case-folding a near-miss into a modelled branch would | ||
| * change how an already-working question is answered — a lower-case | ||
| * `"checkbox"` would flip from verbatim pass-through to boolean normalization. | ||
| * Every value the schema does not declare is `unmodelled`, and every | ||
| * `unmodelled` path behaves exactly as it did before #877. | ||
| */ | ||
| function classifyInputType(inputType) { | ||
| if (inputType !== null && Object.hasOwn(ANSWER_SHAPE_BY_INPUT_TYPE, inputType)) { | ||
| return ANSWER_SHAPE_BY_INPUT_TYPE[inputType]; | ||
| } | ||
| return "unmodelled"; | ||
| } | ||
| /** `"<id>" (<label>)`, or just `"<id>"` when the question carries no label. */ | ||
| function describeQuestion(question) { | ||
| return question.label === null ? `"${question.id}"` : `"${question.id}" (${question.label})`; | ||
| } | ||
| /** | ||
| * Resolve caller-supplied {@link RawSurveyAnswer}s against a {@link Survey} | ||
| * into wire {@link SurveyAnswerInput}s. A question carrying answer options | ||
| * (multiple-choice) has its `value` matched against an option's `value` and | ||
| * the option `id` attached; an option-less question sends the `value` with a | ||
| * `null` id — verbatim for free-text, or `"true"`/`"false"` for a checkbox | ||
| * ({@link CHECKBOX_INPUT_TYPE}, which has no options but accepts only a | ||
| * stringified boolean). Throws `SurveysError(VALIDATION_ERROR)` for an unknown | ||
| * question id, a value matching no option of a multiple-choice question, or a | ||
| * non-boolean checkbox value. | ||
| * into wire {@link SurveyAnswerInput}s. | ||
| * | ||
| * An option-less question sends its `value` with a `null` id — verbatim, or | ||
| * `"true"`/`"false"` for a `CHECKBOX`. An option-bearing question has its | ||
| * `value` matched against an option's `value` and that option's `id` attached. | ||
| * | ||
| * The declared `inputType` ({@link classifyInputType}) then qualifies the | ||
| * option-bearing path, which `answers` emptiness alone cannot: an `OPEN_TEXT` | ||
| * question arriving WITH options is refused rather than validated against its | ||
| * sentinel, a `PROPOSED_ENGAGEMENT_END_DATE` sends its caller-supplied value | ||
| * with a `null` id either way, and a value matching no option is reported as a | ||
| * shape mismatch only for a `CHECKBOX` (which should carry none) — every other | ||
| * type gets the plain value error. | ||
| * | ||
| * Throws `SurveysError(VALIDATION_ERROR)` for an unknown question id, a | ||
| * non-boolean checkbox value, an unanswerable free-text question, or a value | ||
| * matching no option. | ||
| */ | ||
@@ -166,8 +203,13 @@ function buildSurveyAnswers(survey, raw) { | ||
| } | ||
| const shape = classifyInputType(question.inputType); | ||
| // A date is caller-supplied, never chosen from `answers[]` — matching it | ||
| // against the question's options would attach an id no Toptal client sends. | ||
| if (shape === "date") { | ||
| return { questionId: answer.questionId, id: null, value: answer.value }; | ||
| } | ||
| if (question.answers.length === 0) { | ||
| if (question.inputType === CHECKBOX_INPUT_TYPE) { | ||
| if (shape === "boolean") { | ||
| const normalized = answer.value.trim().toLowerCase(); | ||
| if (normalized !== "true" && normalized !== "false") { | ||
| const labelHint = question.label === null ? "" : ` (${question.label})`; | ||
| throw new SurveysError("VALIDATION_ERROR", `Checkbox question "${answer.questionId}"${labelHint} accepts only "true" or "false", got "${answer.value}".`); | ||
| throw new SurveysError("VALIDATION_ERROR", `Checkbox question ${describeQuestion(question)} accepts only "true" or "false", got "${answer.value}".`); | ||
| } | ||
@@ -178,2 +220,10 @@ return { questionId: answer.questionId, id: null, value: normalized }; | ||
| } | ||
| // The options are a sentinel vocabulary, not an answer vocabulary. | ||
| if (shape === "free-text") { | ||
| throw new SurveysError("VALIDATION_ERROR", `ttctl cannot yet answer free-text questions on this survey — answer it in the Toptal ` + | ||
| `web portal instead. Question ${describeQuestion(question)} declares inputType ` + | ||
| `"OPEN_TEXT" but arrived carrying answer options, a shape ttctl has not validated ` + | ||
| `against the live API; submitting a guess would close the survey and forfeit the ` + | ||
| `answer. Progress: https://github.com/alexey-pelykh/ttctl/issues/877.`); | ||
| } | ||
| const option = question.answers.find((o) => o.value === answer.value); | ||
@@ -185,2 +235,12 @@ if (option === undefined) { | ||
| .join(", "); | ||
| // `CHECKBOX` is the one type whose observed shape genuinely contradicts | ||
| // arriving with options. Everything else keeps the plain value error — | ||
| // claiming ttctl cannot model a question it answers on a matching value | ||
| // would be false, and a typo is not a shape surprise. | ||
| if (shape === "boolean") { | ||
| throw new SurveysError("VALIDATION_ERROR", `Question ${describeQuestion(question)} declares inputType "CHECKBOX", which takes ` + | ||
| `"true"/"false" and carries no options — yet it arrived with them, and "${answer.value}" ` + | ||
| `matched none (${valid}). ttctl does not model this question shape; please report it at ` + | ||
| `https://github.com/alexey-pelykh/ttctl/issues so it can be answered correctly.`); | ||
| } | ||
| throw new SurveysError("VALIDATION_ERROR", `"${answer.value}" is not a valid answer for question "${answer.questionId}". Valid values: ${valid}.`); | ||
@@ -197,2 +257,18 @@ } | ||
| /** | ||
| * Warn on stderr when a submission would leave optional questions unanswered. | ||
| * Submitting CLOSES the survey, so an omitted optional question is forfeited | ||
| * permanently — silence there is the harm #877 reports. Advisory only: an | ||
| * omission is a legitimate choice, so this never blocks. stderr keeps `-o json` | ||
| * stdout clean, as `loadConfigFile` does for its file-permission warning. | ||
| */ | ||
| function warnUnansweredOptional(survey, answers) { | ||
| const skipped = survey.questions.filter((q) => q.isMandatory !== true && !answers.some((a) => a.questionId === q.id)); | ||
| if (skipped.length === 0) { | ||
| return; | ||
| } | ||
| process.stderr.write(`warning: submitting survey "${survey.id}" leaves ${skipped.length.toString()} optional question(s) ` + | ||
| `unanswered; submission closes it, so they cannot be answered afterwards: ` + | ||
| `${skipped.map(describeQuestion).join(", ")}\n`); | ||
| } | ||
| /** | ||
| * Resolve {@link SubmitSurveyArgs} into a wire {@link ResolvedSubmission} by | ||
@@ -224,3 +300,3 @@ * fetching the pending-survey list and matching the target survey. Pure | ||
| if (unanswered.length > 0) { | ||
| const names = unanswered.map((q) => (q.label === null ? `"${q.id}"` : `"${q.id}" (${q.label})`)).join(", "); | ||
| const names = unanswered.map(describeQuestion).join(", "); | ||
| throw new SurveysError("VALIDATION_ERROR", `Survey "${args.surveyId}" has unanswered mandatory question(s): ${names}. Run \`ttctl surveys list\` to see all questions.`); | ||
@@ -232,3 +308,7 @@ } | ||
| } | ||
| return { kind, surveyId: args.surveyId, answers: buildSurveyAnswers(survey, args.answers) }; | ||
| const answers = buildSurveyAnswers(survey, args.answers); | ||
| // Only once the submission is known to be well-formed — a warning ahead of a | ||
| // throw would be noise. | ||
| warnUnansweredOptional(survey, args.answers); | ||
| return { kind, surveyId: args.surveyId, answers }; | ||
| } | ||
@@ -235,0 +315,0 @@ /** |
+4
-4
| { | ||
| "name": "@ttctl/core", | ||
| "version": "0.2.0", | ||
| "version": "0.2.1", | ||
| "description": "Core library for Toptal Talent platform integration: API client, auth, services", | ||
@@ -41,6 +41,6 @@ "type": "module", | ||
| "@types/proper-lockfile": "^4.1.4", | ||
| "eslint": "^10.7.0", | ||
| "eslint": "^10.8.1", | ||
| "graphql": "^17.0.2", | ||
| "typescript": "~6.0.3", | ||
| "vitest": "^4.1.9" | ||
| "vitest": "^4.1.10" | ||
| }, | ||
@@ -50,3 +50,3 @@ "dependencies": { | ||
| "proper-lockfile": "^4.1.2", | ||
| "undici": "^8.5.0", | ||
| "undici": "^8.9.0", | ||
| "yaml": "^2.9.0", | ||
@@ -53,0 +53,0 @@ "zod": "^4.4.3" |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2060901
0.24%45611
0.19%Updated