From 7ce36b6c7b42724f1573749b630d66f62b73c736 Mon Sep 17 00:00:00 2001 From: Steven777 Date: Wed, 2 Sep 2026 05:06:40 +0800 Subject: [PATCH 1/5] fix: harden TIS planning and selection contracts --- CHANGELOG.md | 24 +++ README.md | 18 ++- docs/ARCHITECTURE.md | 5 + docs/AUTHENTICATION.md | 7 + docs/OUTPUT.md | 6 + docs/SELECTION_CONTRACTS.md | 60 +++++++ skills/sustech-cli/SKILL.md | 21 ++- src/cli.ts | 107 ++++++++++-- src/core/capabilities.ts | 1 + src/core/command-metadata.ts | 2 + src/core/keyring.ts | 75 ++++++++- src/core/text.ts | 6 +- src/test/cli.test.ts | 18 +++ src/test/client.test.ts | 38 +++++ src/test/keyring.test.ts | 49 ++++++ src/test/planning-projection.test.ts | 70 ++++++++ src/test/selection-bundles.test.ts | 73 +++++++++ src/test/tis-remaining-selection.test.ts | 37 +++++ src/tis/client.ts | 86 +++++++++- src/tis/normalise.ts | 63 +++++++- src/tis/planning-projection.ts | 167 +++++++++++++++++++ src/tis/remaining-selection.ts | 105 +++++++++++- src/tis/remaining-text.ts | 2 + src/tis/selection-bundles.ts | 197 +++++++++++++++++++++++ src/tis/types.ts | 17 +- 25 files changed, 1222 insertions(+), 32 deletions(-) create mode 100644 docs/SELECTION_CONTRACTS.md create mode 100644 src/test/planning-projection.test.ts create mode 100644 src/test/selection-bundles.test.ts create mode 100644 src/tis/planning-projection.ts create mode 100644 src/tis/selection-bundles.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dd40a..f2f74ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +### Added + +- Added normalized lecture/lab selection bundles with explicit component, + credit-bearing, mutation-ID, task-RWH, and read-back contracts. +- Added bounded `tis selection reconcile` reads for uncertain enrollment, + cart, drop, and bid outcomes. + +### Changed + +- Made planning-oriented availability, enrollment, degree-progress, and + degree-missing JSON use documented minimum-data projections; grade-free + output is the default. + +### Fixed + +- Linux Secret Service writes now require an immediate verified read-back and + report actionable locked-collection, D-Bus-session, and access-denied states. + +### Security + +- Selection transport ambiguity now returns an explicit non-retriable outcome + with a local correlation ID, while raw upstream mutation and personal + selection envelopes are excluded from default CLI output. + ## [0.10.0] - 2026-08-29 ### Added diff --git a/README.md b/README.md index 89698a6..861a6e3 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,10 @@ in the operating system's native credential store: The password is entered through a hidden prompt, is never accepted as a normal command-line argument, and is never written to the CLI config. If no safe backend is available, the CLI returns `CREDENTIAL_STORE_UNAVAILABLE` instead of -falling back to plaintext. +falling back to plaintext. Linux writes are verified by immediate read-back; +locked collections and broken desktop D-Bus sessions produce distinct safe +remediation in `auth status --json` instead of being reported as an expired +password. ```bash sustech auth login --profile main @@ -257,6 +260,19 @@ sustech tis enroll apply \ --course-id TIS_INTERNAL_ID --rwh TASK_ID --round bxxk --bid 2 --confirm ``` +Availability JSON groups lecture/lab rows into credit-deduplicated bundles and +labels the exact `courseId` (`p_id`) and component `rwh` roles. If apply returns +`TIS_SELECTION_OUTCOME_UNKNOWN`, preserve that exact pair and reconcile without +repeating the write: + +```bash +sustech tis selection reconcile enroll \ + --course-id TIS_INTERNAL_ID --rwh TASK_ID --round bxxk --attempts 3 --json +``` + +See [docs/SELECTION_CONTRACTS.md](docs/SELECTION_CONTRACTS.md) for bundle, +identifier, bounded reconciliation, and grade-free planning-output contracts. + Blackboard attachment and submission example: ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 53b2e3c..d03d4f5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,6 +145,11 @@ commands for them. - Mutation commands use explicit preview/build phases and post-action verification. Any ambiguous remote result returns exit code 5 plus `DO_NOT_RETRY_AUTOMATICALLY` when write state cannot be determined safely. +- TIS selection previews carry a local correlation ID but never claim upstream + idempotency. Transport ambiguity is reconciled through bounded exact + `{courseId, rwh, round}` reads rather than by repeating a mutation. +- Planning-facing TIS output passes through field-allowlisted projections; + broad upstream rows and raw mutation responses remain outside CLI JSON. - Consequence metadata lives in `src/core/consequences.ts` so agents can inspect risks and follow-up checks without scraping prose. - New authenticated campus-service wrappers are validated with protocol fixtures diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index c8a94fa..f6c2f19 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -68,6 +68,13 @@ Linux deliberately requires a desktop D-Bus session and the distribution's `secret-tool`/`libsecret-tools` package. It does not silently fall back to a plaintext file or a session-only kernel keyring. +Credential writes are verified by an immediate read-back before profile +metadata is committed. Linux errors distinguish a locked collection, a missing +desktop D-Bus/Secret Service session, an access denial, and an unclassified +`secret-tool` failure. Run `sustech auth status --json` in the same unlocked +graphical session and follow its `remediation`; do not delete profile metadata +or assume the password expired merely because the collection is locked. + ## Profiles The default profile is named `default`. Multiple accounts use explicit names: diff --git a/docs/OUTPUT.md b/docs/OUTPUT.md index dfade5c..8333243 100644 --- a/docs/OUTPUT.md +++ b/docs/OUTPUT.md @@ -72,6 +72,12 @@ Credential commands return backend, profile, availability, and masked account metadata only. Passwords, cookies, bearer tokens, and keyring values are never part of text, JSON, JSONL, error details, or capability output. +Planning-oriented TIS commands additionally use field allowlists. Available +courses are emitted as normalized bundles, enrolled rows omit broad description +fields, and `tis degree missing` omits letter grades and numeric scores. Course +grades in `tis degree progress` require the explicit `--details` option. See +`SELECTION_CONTRACTS.md` for exact bundle, identifier, and projection semantics. + Blackboard attachment listings likewise omit signed `bbcswebdav` URLs. A successful `bb download` result contains only stable attachment metadata, the absolute destination path, byte count, content type, and SHA-256. diff --git a/docs/SELECTION_CONTRACTS.md b/docs/SELECTION_CONTRACTS.md new file mode 100644 index 0000000..da4f4f0 --- /dev/null +++ b/docs/SELECTION_CONTRACTS.md @@ -0,0 +1,60 @@ +# TIS selection contracts + +The selection surface separates catalog rows, selectable bundles, mutation identifiers, and read-back identifiers. Consumers must not infer one identifier's meaning from its spelling. + +## Bundled availability + +`sustech tis courses available ... --json` returns `data.bundles`. A bundle contains: + +- `bundleId`: an explicit source bundle ID when TIS exposes one; otherwise a stable selection/task-scoped identity. +- `components`: lecture, lab, tutorial, other, or unknown rows. Every component states whether it is required and identifies its task `rwh`. +- `credits` and `creditStatus`: equal repeated component credits are counted once. Conflicting component credits produce `creditStatus: "ambiguous"` and omit `credits` instead of guessing or summing. +- `teachingTeam` and `meetings`: unions across all components, retaining parity-week schedules. +- `operationTargets`: exact component-level mutation `courseId`, task `rwh`, payload field, and read-back identity. +- `selectableWithoutGuessing`: true only when every required component has the explicit identifier pair needed for mutation and verification. + +Duplicate source rows for the same component are merged and reported in `warnings`. Default CLI output never contains the upstream selection envelope, enrolled/cart raw rows, credentials, cookies, tokens, or unrelated student fields. `retainCourseSourceRecord` is a library-level diagnostics-only escape hatch and is not called by CLI commands. + +## Identifier meanings + +| Name | Meaning | Accepted by | +| --- | --- | --- | +| `bundleId` | normalized course bundle identity | display, planning, grouping only | +| `componentId` / `taskId` / `rwh` | exact teaching-task component | required together with `courseId` for apply and reconciliation read-back | +| `courseId` | opaque selection mutation identifier | CLI `--course-id`; serialized as upstream `p_id` | +| `clientRequestId` | local correlation identifier | output/errors only; it is not an upstream idempotency key | + +Do not pass `bundleId`, course code, or `rwh` as `--course-id`. Do not treat `courseId` alone as a unique lecture/lab component: exact verification keys on `{courseId, rwh}`. + +## Uncertain writes and reconciliation + +TIS does not currently expose a verified idempotency-key facility for these endpoints. Every preview therefore says `upstreamKeySupported: false` and `automaticRetry: "forbidden"`. The generated `clientRequestId` is not added to the upstream payload. + +If a request is known to fail before submission, the CLI returns `TIS_SELECTION_NOT_SUBMITTED`, exit 4, and `NO_MUTATION_PERFORMED`. If submission may have started but no conclusive response arrives, it returns `TIS_SELECTION_OUTCOME_UNKNOWN`, exit 5, and `DO_NOT_RETRY_AUTOMATICALLY`. + +Use the exact target from the error: + +```bash +sustech tis selection reconcile cart.add \ + --course-id SELECTION_ID --rwh TASK_ID --round bxxk \ + --attempts 3 --json +``` + +Reconciliation performs two to five bounded read-only queries and reports: + +- `applied`: the final bounded observation reached the requested exact state; +- `not_applied`: at least two consistent exact observations retained the inverse state and no desired/conflicting observation appeared; +- `still_uncertain`: a query failed, identifiers conflicted, observations regressed, or evidence remained incomplete. + +None of these states authorizes an automatic mutation retry. `not_applied` means a human or higher-level workflow may review a new preview; it does not reuse the uncertain request. + +## Privacy-minimized planning projections + +Planning commands use documented allowlists: + +- `tis courses available`: normalized bundles, components, exact identifiers, teaching teams, meetings, capacity/credit context, and report time; +- `tis enrolled`: course identity, exact `rwh`, teaching team, and meeting coordinates; +- `tis degree progress`: summary/category/module data by default; course grades appear only with explicit `--details`; +- `tis degree missing`: completion classification may guide gap reasoning, but letter grades and numeric scores are removed from both text and JSON. + +The projection guard rejects credential, cookie, token, raw-envelope, SID, and unrelated student-identifier keys before output. diff --git a/skills/sustech-cli/SKILL.md b/skills/sustech-cli/SKILL.md index aa78dc2..119d8d9 100644 --- a/skills/sustech-cli/SKILL.md +++ b/skills/sustech-cli/SKILL.md @@ -52,8 +52,8 @@ includes these high-value areas: `tis ical`, `tis degree progress`, `tis degree missing`, `tis degree audit`. - TIS writes: `tis selection preview/apply` for `cart`, `drop`, and `bid` - style operations, `tis bid plan`, `tis bid apply`, `tis enroll preview`, - `tis enroll apply`. + style operations, read-only `tis selection reconcile`, `tis bid plan`, `tis + bid apply`, `tis enroll preview`, `tis enroll apply`. - Other authenticated campus services: `ws programs`, `ws detail`, `library search`, `library detail`, `booking whoami`, `booking rooms`, `booking my-meetings`, `booking create preview/apply`, @@ -132,6 +132,14 @@ direct CLI and the approval workflow below when state must change. `meta`, and `schemaVersion` in the output envelope. - Do not parse human-readable text when a JSON mode is available. - Preserve IDs exactly as returned; Blackboard and TIS IDs are opaque strings. +- For `tis courses available`, consume `data.bundles`, count bundle credits + once, include every required component, and use only its documented + `operationTargets`. `courseId` becomes upstream `p_id`; `rwh` identifies the + exact component for read-back. Never guess one from the other. +- Planning output is minimum-data by default. `tis degree missing` is + grade-free, and course grades in `tis degree progress` require an explicit + `--details`; do not request details when summary/category/module evidence is + sufficient. ## Handle credentials @@ -147,6 +155,10 @@ direct CLI and the approval workflow below when state must change. mechanism. Never create a plaintext fallback. - Use `sustech auth status --json` and the appropriate read-only `sustech auth check --service ... --json` before a workflow that needs login. +- On Linux, a locked Secret Service collection or missing desktop D-Bus session + is not evidence that credentials expired. Follow the structured + `remediation`, keep profile metadata intact, and retry only after the same + graphical login collection is unlocked. - Use `--profile` when the task depends on a specific account identity. - If CAS returns `CAS_INTERACTIVE_CHALLENGE_REQUIRED`, report that the password was not submitted and stop. Do not bypass, solve, or repeatedly retry the @@ -201,7 +213,10 @@ Important command-specific rules: - For file-bound applies such as Blackboard submission and PMS upload, preserve the exact previewed SHA-256 into apply. - If a result is ambiguous or contains `DO_NOT_RETRY_AUTOMATICALLY`, stop and - report it; do not retry the mutation automatically. + report it; do not retry the mutation automatically. For a TIS selection + timeout, run bounded `tis selection reconcile OP` with the exact + `courseId`/`rwh`/round from the error. Its `applied`, `not_applied`, or + `still_uncertain` result is read-only and never authorizes an automatic retry. ## Guard local writes and exports diff --git a/src/cli.ts b/src/cli.ts index a1f7c44..c752149 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -128,6 +128,14 @@ import { } from "./tis/course-decision.js"; import { formatCourseRecommendationReport } from "./tis/course-decision-text.js"; import { deriveTisDegreeMissing } from "./tis/degree-missing.js"; +import { + PLANNING_PROJECTION_FIELDS, + assertPlanningProjection, + projectDegreeMissingForPlanning, + projectDegreeProgressForPlanning, + projectEnrollmentForPlanning, + projectSelectionRoundForPlanning, +} from "./tis/planning-projection.js"; import { parseBlockedTime, solveTimetables } from "./tis/planner.js"; import { addPlanEntries, createPlanDocument, loadPlan, removePlanEntries, savePlan } from "./tis/plan.js"; import { @@ -146,6 +154,7 @@ import { parseShenzhenExamTimeRange, planBidUpdates, projectBidTotal, + reconcileSelectionSnapshots, revalidateSelectionWrite, resolveLiveRoom, scheduleIcsEvents, @@ -442,6 +451,7 @@ Usage: sustech tis degree missing [--semester YYYY-YYYY-N] sustech tis degree audit --requirements FILE [--semester YYYY-YYYY-N] sustech tis selection preview OP --course-id ID [--rwh RWH] [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] + sustech tis selection reconcile OP --course-id ID --rwh RWH [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] [--attempts 2-5] sustech tis selection apply OP --course-id ID --rwh RWH [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] --confirm sustech tis bid plan --pick COURSE_ID:BID|RWH:COURSE_ID:BID [--pick ...] [--semester YYYY-YYYY-N] [--bid-limit N] [--where cart|enrolled] [--round ROUND] [--cultivation 1|2] sustech tis bid apply --pick RWH:COURSE_ID:BID [--pick ...] [--semester YYYY-YYYY-N] [--where cart|enrolled] [--round ROUND] [--cultivation 1|2] --confirm @@ -562,6 +572,7 @@ type Values = OutputFlags & { path?: string; requirements?: string; details?: boolean; + attempts?: string; since?: string; until?: string; "url-stdin"?: boolean; @@ -773,13 +784,21 @@ async function main(argv: string[]): Promise { if (limit > 500) throw usageError("--limit cannot exceed 500 for selectable-course queries."); const keyword = parsed.positionals.slice(3).join(" ") || undefined; const result = await client.searchAvailable(semester, { keyword, round, limit }); - const data = { semester, ...result }; + const data = { + semester, + round: projectSelectionRoundForPlanning(result.round), + bundles: result.bundles, + total: result.total, + reportedAt: result.reportedAt, + projection: { mode:"planning-minimum", fieldAllowlist:PLANNING_PROJECTION_FIELDS.availability }, + }; + assertPlanningProjection(data); writeSuccess({ command: "tis courses available", data, text: formatAvailableCourses({ semester, courses: result.courses, total: result.total, round }), - items: result.courses, - summary: { semester: semester.value, round, total: result.total, shown: result.courses.length }, + items: result.bundles, + summary: { semester: semester.value, round, total: result.total, shown: result.bundles.length }, meta: { enrolledCount: result.enrolled.length, cartCount: result.cart.length }, }, output); return; @@ -788,13 +807,20 @@ async function main(argv: string[]): Promise { const semester = parseSemester(values.semester); const client = await tisClient(values); const courses = await client.enrolled(semester); - const data = { semester, courses, total: courses.length }; + const projectedCourses = projectEnrollmentForPlanning(courses); + const data = { + semester, + courses: projectedCourses, + total: projectedCourses.length, + reportedAt: new Date().toISOString(), + projection: { mode:"planning-minimum", fieldAllowlist:PLANNING_PROJECTION_FIELDS.enrollment }, + }; writeSuccess({ command: "tis enrolled", data, text: formatEnrolledCourses(semester, courses), - items: courses, - summary: { semester: semester.value, total: courses.length }, + items: projectedCourses, + summary: { semester: semester.value, total: projectedCourses.length }, }, output); return; } @@ -1352,6 +1378,7 @@ async function main(argv: string[]): Promise { { operation: selectionOperation, courseId, + ...(rwh ? { rwh } : {}), ...(values.round ? { round: opaqueToken(values.round, "--round") } : {}), bid, where, @@ -1394,6 +1421,54 @@ async function main(argv: string[]): Promise { }, output); return; } + if (command === "selection" && operation === "reconcile" && parsed.positionals.length === 4) { + const selectionOperation = selectionOperationValue(required(parsed.positionals[3], "selection operation")); + const semester = parseSemester(values.semester); + const cultivation = selectionCultivation(values.cultivation); + const target = selectionApplyTarget(values, selectionOperation); + const attempts = parsePositiveInteger(values.attempts, 3, "--attempts"); + if (attempts < 2 || attempts > 5) throw usageError("--attempts must be between 2 and 5."); + const client = await tisClient(values); + const states: TisSelectionState[] = []; + const readErrors: string[] = []; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + states.push(await client.selectionState(semester, { + keyword: "", + round: target.round, + limit: 500, + cultivation, + })); + } catch (error) { + readErrors.push(error instanceof CliError ? error.code : "UNKNOWN_READ_ERROR"); + } + if (attempt < attempts) await boundedWait(750); + } + const base = reconcileSelectionSnapshots(states, target); + const reconciliation = readErrors.length > 0 + ? { + ...base, + status: "still_uncertain" as const, + readErrors, + message: "At least one bounded read-back failed; the outcome remains uncertain and must not be retried automatically.", + } + : base; + writeSuccess({ + command: "tis selection reconcile", + data: { semester, cultivation, reconciliation }, + text: [ + `Reconciliation: ${reconciliation.status}`, + `Exact target: ${target.courseId} / ${target.rwh} / ${target.round}`, + `Read-back attempts: ${attempts}`, + reconciliation.message, + "Automatic retry: forbidden", + ].join("\n"), + items: reconciliation.observations, + summary: { status: reconciliation.status, attempts, readErrors: readErrors.length }, + meta: { warning: "DO_NOT_RETRY_AUTOMATICALLY" }, + }, output); + return; + } if (command === "selection" && operation === "apply" && parsed.positionals.length === 4) { const selectionOperation = selectionOperationValue(required(parsed.positionals[3], "selection operation")); if (selectionOperation === "enroll") { @@ -1431,6 +1506,7 @@ async function main(argv: string[]): Promise { { operation: target.operation, courseId: target.courseId, + rwh: target.rwh, round: target.round, bid: target.bid, where: target.where, @@ -1441,6 +1517,7 @@ async function main(argv: string[]): Promise { throw new CliError(result.message || "TIS rejected the selection mutation.", "TIS_WRITE_REJECTED", 4, { target, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } let verification; @@ -1595,6 +1672,7 @@ async function main(argv: string[]): Promise { { operation: "bid.update", courseId: pick.courseId, + rwh: pick.rwh!, round, bid: pick.bid, where, @@ -1608,6 +1686,7 @@ async function main(argv: string[]): Promise { confirmed, unchanged, tisCode: result.jg, + clientRequestId: result.clientRequestId, result, warning: "DO_NOT_RETRY_AUTOMATICALLY", }); @@ -1617,6 +1696,7 @@ async function main(argv: string[]): Promise { confirmed, unchanged, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } try { @@ -1711,11 +1791,12 @@ async function main(argv: string[]): Promise { if (command === "degree" && operation === "progress" && parsed.positionals.length === 3) { const client = await tisClient(values); const progress = await client.degreeProgress({ details: values.details === true }); + const projectedProgress = projectDegreeProgressForPlanning(progress, { includeGrades: values.details === true }); writeSuccess({ command: "tis degree progress", - data: progress, + data: projectedProgress, text: formatDegreeProgress(progress), - ...(progress.detailsIncluded && progress.courses ? { items: progress.courses } : {}), + ...(projectedProgress.detailsIncluded && projectedProgress.courses ? { items: projectedProgress.courses } : {}), summary: { dataAvailable: progress.dataAvailable, detailsRequested: progress.detailsRequested, @@ -1736,10 +1817,11 @@ async function main(argv: string[]): Promise { const semester = values.semester ? parseSemester(values.semester) : undefined; const client = await tisClient(values); const report = await deriveTisDegreeMissing(client, { semester }); + const projectedReport = projectDegreeMissingForPlanning(report); writeSuccess({ command: "tis degree missing", - data: report, - text: formatDegreeMissing(report), + data: projectedReport, + text: formatDegreeMissing(projectedReport), summary: { definiteMissingRequiredCourses: report.counts.definiteMissingRequiredCourses, inProgressRequiredCourses: report.counts.inProgressRequiredCourses, @@ -1784,6 +1866,7 @@ async function main(argv: string[]): Promise { action: "enroll", rwh: target.rwh, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } let verification: { status: "confirmed" | "not_observed" | "unavailable"; message: string }; @@ -5890,6 +5973,10 @@ function defaultSelectionBid(operation: SelectionOperation): number { return operation === "drop" || operation === "cart.remove" ? 1 : 1; } +function boundedWait(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + function buildEnrollApplyCommand(target: { semester: ReturnType; courseId: string; diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index deb89d9..473182d 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -125,6 +125,7 @@ export const CAPABILITIES: readonly Capability[] = [ capability("tis degree audit", "Audit live grade records against a local JSON requirement file without auto-resolving ambiguous matches.", "plan", { authentication: "tis", status: "preview" }), capability("tis enroll preview", "Build an exact enrollment action without network or mutation.", "plan", { network: false, status: "preview" }), capability("tis selection preview", "Build typed enroll, drop, cart, or bid payloads without sending them, with optional exact RWH handoff for apply.", "plan", { network: false, status: "preview" }), + capability("tis selection reconcile", "Poll an exact courseId/RWH/round target after an uncertain selection write without repeating the mutation.", "read", { authentication: "tis", status: "preview" }), capability("tis selection apply", "Submit an exact cart, drop, or bid mutation to TIS after live revalidation.", "mutation", { authentication: "tis", confirmation: "required", status: "preview" }), capability("tis bid plan", "Validate a multi-course bid budget and build local write previews.", "plan", { network: false, status: "preview" }), capability("tis bid apply", "Submit exact multi-course bid updates to TIS after live revalidation.", "mutation", { authentication: "tis", confirmation: "required", status: "preview" }), diff --git a/src/core/command-metadata.ts b/src/core/command-metadata.ts index 1f7f931..0e2a719 100644 --- a/src/core/command-metadata.ts +++ b/src/core/command-metadata.ts @@ -91,6 +91,7 @@ export const CLI_PARSE_OPTIONS = { path: { type: "string" }, requirements: { type: "string" }, details: { type: "boolean", default: false }, + attempts: { type: "string" }, since: { type: "string" }, until: { type: "string" }, "url-stdin": { type: "boolean", default: false }, @@ -239,6 +240,7 @@ export const COMMAND_OPTIONS: Readonly> "tis degree missing": ["credentials-file", "semester"], "tis enroll preview": ["semester", "course-id", "rwh", "round", "bid"], "tis selection preview": ["semester", "course-id", "rwh", "round", "bid", "where", "cultivation"], + "tis selection reconcile": ["credentials-file", "semester", "course-id", "rwh", "round", "bid", "where", "cultivation", "attempts"], "tis selection apply": ["credentials-file", "semester", "course-id", "rwh", "round", "bid", "where", "cultivation", "confirm"], "tis bid plan": ["semester", "pick", "bid-limit", "where", "round", "cultivation"], "tis bid apply": ["credentials-file", "semester", "pick", "where", "round", "cultivation", "confirm"], diff --git a/src/core/keyring.ts b/src/core/keyring.ts index d7f43cd..a8cad68 100644 --- a/src/core/keyring.ts +++ b/src/core/keyring.ts @@ -157,10 +157,25 @@ export async function saveStoredCredentials( const account = existing?.account ?? credentialAccount(profile, sid); let previousPassword: string | undefined; + let secretTouched = false; try { previousPassword = await store.get(account); await store.set(account, password); + secretTouched = true; + const verifiedPassword = await store.get(account); + if (verifiedPassword !== password) { + throw credentialStoreDiagnostic( + "Credential store write could not be verified by an immediate read-back.", + "CREDENTIAL_WRITE_NOT_VERIFIED", + store.backend === "linux-secret-service" + ? "Unlock the desktop login keyring, then run auth login again in the same graphical session." + : "Unlock the operating-system credential store, then run auth login again.", + ); + } } catch (error) { + if (secretTouched && !await restoreSecret(store, account, previousPassword)) { + throw credentialRollbackError("save", store.backend); + } throw storeAccessError("credentials", "write", store.backend, error); } @@ -295,6 +310,7 @@ export async function getCredentialStatus( storedAt: stored.storedAt, profiles, reason: safeStoreReason(error), + ...(storeRemediation(error) ? { remediation: storeRemediation(error) } : {}), }; } } @@ -573,7 +589,7 @@ async function resolveLinuxSecretService( "lookup", "service", namespace.service, "account", account, ], undefined, env); if (result.code === 1 && !result.stdout.trim() && !result.stderr.trim()) return undefined; - if (result.code !== 0) throw new Error("Secret Service lookup failed."); + if (result.code !== 0) throw secretServiceCommandError("lookup", result); return result.stdout.replace(/\r?\n$/, "") || undefined; }, async set(account, password) { @@ -583,14 +599,14 @@ async function resolveLinuxSecretService( "service", namespace.service, "account", account, ], `${password}\n`, env); - if (result.code !== 0) throw new Error("Secret Service write failed."); + if (result.code !== 0) throw secretServiceCommandError("write", result); }, async delete(account) { const result = await runCredentialCommand(executable, [ "clear", "service", namespace.service, "account", account, ], undefined, env); if (result.code === 1 && !result.stderr.trim()) return false; - if (result.code !== 0) throw new Error("Secret Service delete failed."); + if (result.code !== 0) throw secretServiceCommandError("delete", result); return true; }, }; @@ -677,10 +693,52 @@ function storeAccessError(subject: string, operation: string, backend: Credentia `Could not ${operation} ${subject} using ${backend}.`, "CREDENTIAL_STORE_ERROR", 2, - { backend, operation, reason: safeStoreReason(error) }, + { + backend, + operation, + reason: safeStoreReason(error), + ...(storeRemediation(error) ? { remediation: storeRemediation(error) } : {}), + }, ); } +function secretServiceCommandError( + operation: "lookup" | "write" | "delete", + result: { code: number; stderr: string }, +): Error { + const stderr = result.stderr.slice(0, 4096); + if (/locked|is locked|collection.*lock/i.test(stderr)) { + return credentialStoreDiagnostic( + "The Secret Service collection is locked.", + "SECRET_SERVICE_LOCKED", + "Unlock the desktop login keyring and retry in the same graphical session; do not delete the profile metadata.", + ); + } + if (/D-Bus|dbus|cannot autolaunch|serviceunknown|connection (?:refused|closed)|no such file/i.test(stderr)) { + return credentialStoreDiagnostic( + "The desktop Secret Service session is unavailable.", + "SECRET_SERVICE_SESSION_UNAVAILABLE", + "Run the command inside the same unlocked desktop session that owns DBUS_SESSION_BUS_ADDRESS, or inject process-scoped credentials from an external secret manager.", + ); + } + if (/denied|permission|dismissed|cancelled/i.test(stderr)) { + return credentialStoreDiagnostic( + "The Secret Service request was denied.", + "SECRET_SERVICE_ACCESS_DENIED", + "Allow the keyring prompt and verify that the login collection is unlocked before retrying.", + ); + } + return credentialStoreDiagnostic( + `Secret Service ${operation} failed with exit code ${result.code}.`, + "SECRET_SERVICE_COMMAND_FAILED", + "Run `sustech auth status --json` in the desktop session and verify secret-tool can access the unlocked login collection.", + ); +} + +function credentialStoreDiagnostic(message: string, code: string, remediation: string): Error { + return Object.assign(new Error(message), { code, remediation }); +} + function validateStoredSecret(value: string, subject: string, code: string): string { if (!value || /[\r\n]/.test(value) || Buffer.byteLength(value, "utf8") > 16 * 1024) { throw new CliError( @@ -725,12 +783,21 @@ function credentialRollbackError(operation: "save" | "delete", backend: Credenti } function safeStoreReason(error: unknown): string { + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { + if (/^(?:The Secret Service|Secret Service|Credential store write)/.test(error.message)) return error.message; + } if (error && typeof error === "object" && "code" in error) return `Credential store error ${String(error.code)}.`; return error instanceof Error && /^Secret Service /.test(error.message) ? error.message : "The operating-system credential store rejected or could not complete the request."; } +function storeRemediation(error: unknown): string | undefined { + return error && typeof error === "object" && "remediation" in error && typeof error.remediation === "string" + ? error.remediation + : undefined; +} + async function readCredentialConfig(options: CredentialStoreOptions): Promise { const path = credentialConfigPath(options); let raw: string; diff --git a/src/core/text.ts b/src/core/text.ts index 1901f30..18777f5 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -66,10 +66,12 @@ export function formatEnrolledCourses(semester: Semester, courses: PersonalSched const header = `Enrolled courses · ${semester.value}`; if (courses.length === 0) return `${header}\n\nNo enrolled courses returned by TIS.`; const blocks = courses.map((course, index) => { - const name = course.courseName || course.description || course.descriptionEn || "Unnamed course"; + const name = course.courseName || course.rwh || "Unnamed course"; const details = [ course.teacher && `Teacher: ${course.teacher}`, - course.description && `Time: ${course.description}`, + course.day !== undefined && course.periodStart !== undefined + ? `Day ${course.day}, period ${course.periodStart}-${course.periodEnd ?? course.periodStart}` + : "", course.room && `Room: ${course.room}`, ].filter(Boolean).join(" · "); return `${index + 1}. ${course.courseCode ? `${course.courseCode} — ` : ""}${name}${details ? `\n ${details}` : ""}`; diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 6b92f4b..bfddf84 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -904,6 +904,24 @@ test("selection and bid apply require --confirm before any credential lookup or assert.equal(JSON.parse(bid.stdout).error.code, "CONFIRMATION_REQUIRED"); }); +test("selection reconciliation is bounded, read-only, and validates locally before credentials", () => { + const invalid = runWithoutCredentials([ + "tis", "selection", "reconcile", "cart.add", + "--course-id", "selection-id", + "--rwh", "task-id", + "--round", "bxxk", + "--attempts", "1", + "--json", + ]); + assert.equal(invalid.status, 2); + assert.equal(JSON.parse(invalid.stdout).error.code, "USAGE"); + + const capabilities = JSON.parse(run(["capabilities", "--json"]).stdout).data.capabilities; + const reconcile = capabilities.find((entry: { command:string }) => entry.command === "tis selection reconcile"); + assert.equal(reconcile.kind, "read"); + assert.equal(reconcile.confirmation, "none"); +}); + test("context live supports calendar level and degrades gracefully when credentials are unavailable", () => { const result = runWithoutCredentials(["context", "--calendar-level", "graduate", "--live", "--json"]); assert.equal(result.status, 0); diff --git a/src/test/client.test.ts b/src/test/client.test.ts index c839ea5..027fafe 100644 --- a/src/test/client.test.ts +++ b/src/test/client.test.ts @@ -3,9 +3,11 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { CliError } from "../core/errors.js"; import type { Semester } from "../core/semester.js"; import type { TisSession } from "../tis/auth.js"; import { TisClient } from "../tis/client.js"; +import { buildSelectionPreview } from "../tis/remaining-selection.js"; const SEMESTER: Semester = { xn: "2025-2026", xq: "1", value: "2025-2026-1" }; @@ -60,6 +62,42 @@ test("available-course parsing preserves nested round metadata", async () => { assert.deepEqual(result.round, { xkfsdm: "bxxk", lcmc: "通识必修" }); }); +test("selection mutation transport failures distinguish known pre-send failure from uncertain submission", async () => { + const preview = buildSelectionPreview({ semester:SEMESTER, cultivation:"1", currentTerm:{} }, { + operation:"cart.add", + courseId:"selection-id", + rwh:"task-id", + clientRequestId:"00000000-0000-4000-8000-000000000001", + }); + const beforeSend = new TisClient({ + async postForm(): Promise { + throw new CliError("synthetic", "NETWORK_ERROR", 1, { requestPhase:"before-send" }); + }, + } as unknown as TisSession); + await assert.rejects( + beforeSend.selectionWrite(preview), + (error: unknown) => error instanceof CliError + && error.code === "TIS_SELECTION_NOT_SUBMITTED" + && error.exitCode === 4 + && error.details?.warning === "NO_MUTATION_PERFORMED", + ); + + const afterSend = new TisClient({ + async postForm(): Promise { + throw new CliError("synthetic", "NETWORK_TIMEOUT", 1); + }, + } as unknown as TisSession); + await assert.rejects( + afterSend.selectionWrite(preview), + (error: unknown) => error instanceof CliError + && error.code === "TIS_SELECTION_OUTCOME_UNKNOWN" + && error.exitCode === 5 + && error.details?.warning === "DO_NOT_RETRY_AUTOMATICALLY" + && (error.details.target as { clientRequestId?: string; rwh?: string })?.clientRequestId === preview.clientRequestId + && (error.details.target as { rwh?: string })?.rwh === "task-id", + ); +}); + test("grade filtering accepts both compact and descriptive TIS semester labels", async () => { const session = { async postJson(): Promise { diff --git a/src/test/keyring.test.ts b/src/test/keyring.test.ts index 832b395..0f718bb 100644 --- a/src/test/keyring.test.ts +++ b/src/test/keyring.test.ts @@ -90,6 +90,40 @@ test("saving a named profile never changes the implicit default profile", async } }); +test("an unverified credential write is rolled back before metadata is committed", async () => { + const configDir = await mkdtemp(join(tmpdir(), "sustech-cli-keyring-verify-")); + const store = new MemoryStore(); + const normalGet = store.get.bind(store); + const normalSet = store.set.bind(store); + let hideNextRead = false; + store.set = async (account: string, password: string) => { + await normalSet(account, password); + hideNextRead = true; + }; + store.get = async (account: string) => { + if (hideNextRead) { + hideNextRead = false; + return undefined; + } + return normalGet(account); + }; + try { + await assert.rejects( + saveStoredCredentials({ sid:"12410000", password:"secret" }, { configDir, store }), + (error: unknown) => error instanceof CliError + && error.code === "CREDENTIAL_STORE_ERROR" + && error.details?.reason === "Credential store write could not be verified by an immediate read-back.", + ); + assert.equal(store.values.size, 0); + await assert.rejects(readFile(join(configDir, "credentials.json"), "utf8"), (error: unknown) => { + assert.equal((error as NodeJS.ErrnoException).code, "ENOENT"); + return true; + }); + } finally { + await rm(configDir, { recursive:true, force:true }); + } +}); + test("Blackboard calendar links are stored by profile without on-disk metadata", async () => { const configDir = await mkdtemp(join(tmpdir(), "sustech-cli-bb-calendar-keyring-")); const store = new MemoryStore(); @@ -260,6 +294,10 @@ case "$1" in printf '%s' "$password" > "$FAKE_SECRET_STATE" ;; lookup) + if [ "$FAKE_SECRET_LOOKUP_LOCKED" = "1" ]; then + printf 'The collection is locked\\n' >&2 + exit 1 + fi if [ -s "$FAKE_SECRET_STATE" ]; then /bin/cat "$FAKE_SECRET_STATE" printf '\\n' @@ -290,6 +328,17 @@ esac assert.equal(loaded.password, "secret with spaces"); assert.equal(loaded.backend, "linux-secret-service"); + fakeEnv.FAKE_SECRET_LOOKUP_LOCKED = "1"; + await assert.rejects( + loadStoredCredentials(undefined, storeOptions), + (error: unknown) => error instanceof CliError + && error.code === "CREDENTIAL_STORE_ERROR" + && error.details?.reason === "The Secret Service collection is locked." + && typeof error.details.remediation === "string" + && /Unlock/.test(error.details.remediation), + ); + delete fakeEnv.FAKE_SECRET_LOOKUP_LOCKED; + fakeEnv.FAKE_SECRET_CLEAR_ERROR = "1"; await assert.rejects( deleteStoredCredentials(undefined, storeOptions), diff --git a/src/test/planning-projection.test.ts b/src/test/planning-projection.test.ts new file mode 100644 index 0000000..b504c87 --- /dev/null +++ b/src/test/planning-projection.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertPlanningProjection, + projectDegreeMissingForPlanning, + projectDegreeProgressForPlanning, + projectEnrollmentForPlanning, + projectSelectionRoundForPlanning, +} from "../tis/planning-projection.js"; +import type { TisDegreeMissing } from "../tis/degree-missing.js"; +import type { TisDegreeProgress } from "../tis/degree-progress.js"; + +test("planning degree projections are grade-free unless details were explicit", () => { + const progress = { + schemaVersion:"1", kind:"tis-degree-progress", reportedAt:"2026-09-02T00:00:00.000Z", + context:{ major:"Synthetic" }, summary:{ remainingCredits:3 }, creditCategories:[], moduleRequirements:[], moduleGaps:[], + dataAvailable:true, detailsRequested:true, detailsIncluded:true, + courses:[{ code:"CS101", name:"Synthetic", credits:3, letterGrade:"A", numericScore:95 }], courseCount:1, + sourceStatuses:{ + graduationRequirements:{ state:"available", count:1 }, requirementSummary:{ state:"available", count:1 }, + creditCategories:{ state:"empty", count:0 }, moduleRequirements:{ state:"empty", count:0 }, courses:{ state:"available", count:1 }, + }, warnings:[], + } satisfies TisDegreeProgress; + const safe = projectDegreeProgressForPlanning(progress); + assert.equal("letterGrade" in safe.courses![0]!, false); + assert.equal("numericScore" in safe.courses![0]!, false); + const explicit = projectDegreeProgressForPlanning(progress, { includeGrades:true }); + assert.equal(explicit.courses![0]!.numericScore, 95); +}); + +test("degree-missing and enrollment projections expose planning fields without raw grades or descriptions", () => { + const report = { + schemaVersion:"1", kind:"tis-degree-missing", generatedAt:"2026-09-02T00:00:00.000Z", reportedAt:"2026-09-02T00:00:00.000Z", + context:{}, officialSummary:{}, summary:{}, + advisory:{ primaryReference:"applicable-official-cultivation-plan", message:"Synthetic", contact:"Synthetic" }, + definiteMissingRequiredCourses:[{ + code:"CS101", name:"Synthetic", groups:[], categories:[], reason:"not complete", + latestAttempt:{ semester:"2025-2026-1", letterGrade:"F", numericScore:40, completion:"failed" }, + }], + inProgressRequiredCourses:[], choiceGaps:[], manualReview:[], + counts:{ definiteMissingRequiredCourses:1, inProgressRequiredCourses:0, choiceGaps:0, manualReview:0 }, + sourceStatuses:{ + progressDetails:{ state:"available", count:1 }, + progress:{ graduationRequirements:{state:"available"}, requirementSummary:{state:"available"}, creditCategories:{state:"empty"}, moduleRequirements:{state:"empty"}, courses:{state:"available"} }, + grades:{ state:"available", count:1 }, enrolled:{ state:"available", count:1 }, + }, warnings:[], + } as TisDegreeMissing; + const projected = projectDegreeMissingForPlanning(report); + assert.deepEqual(projected.definiteMissingRequiredCourses[0]?.latestAttempt, { + semester:"2025-2026-1", completion:"failed", + }); + + const enrollment = projectEnrollmentForPlanning([{ + rwh:"task-1", key:"xq1_jc1", courseCode:"CS101", courseName:"Synthetic", teacher:"Example Teacher", room:"Room 1", + description:"unrelated broad text", descriptionEn:"unrelated broad text", day:1, periodStart:1, periodEnd:2, weeks:[1, 2], + }]); + assert.deepEqual(Object.keys(enrollment[0]!).sort(), ["courseCode", "courseName", "meetings", "rwh", "teachingTeam"]); + assert.equal(JSON.stringify(enrollment).includes("unrelated broad text"), false); +}); + +test("planning projection guard rejects secret and unrelated identity fields", () => { + assert.throws(() => assertPlanningProjection({ courseCode:"CS101", token:"secret" }), /forbidden field/); + assert.throws(() => assertPlanningProjection({ studentId:"123" }), /forbidden field/); +}); + +test("selection-round projection keeps only planning metadata", () => { + assert.deepEqual(projectSelectionRoundForPlanning({ + xkfsdm:"bxxk", lcmc:"Synthetic round", jffs:"20", studentId:"not-for-output", unrelated:"drop", + }), { code:"bxxk", name:"Synthetic round", bidLimit:20 }); +}); diff --git a/src/test/selection-bundles.test.ts b/src/test/selection-bundles.test.ts new file mode 100644 index 0000000..ac8a95b --- /dev/null +++ b/src/test/selection-bundles.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normaliseCourse } from "../tis/normalise.js"; +import { bundleSelectionCourses, retainCourseSourceRecord } from "../tis/selection-bundles.js"; + +test("lecture/lab rows become one selectable bundle with credits counted once", () => { + const lecture = normaliseCourse({ + bundleId: "CS101-A", + componentType: "lecture", + componentRequired: true, + id: "selection-lecture", + rwh: "task-lecture", + kcdm: "CS101", + kcmc: "Synthetic Systems", + kxh: "A", + rwmc: "Lecture A", + xf: 3, + dgjsmc: "Example Lecturer", + kcxx: '

1-16周,星期一第1-2节 Room 101

', + }); + const lab = normaliseCourse({ + bundleId: "CS101-A", + componentType: "lab", + componentRequired: true, + id: "selection-lab", + rwh: "task-lab", + kcdm: "CS101", + kcmc: "Synthetic Systems", + kxh: "A", + rwmc: "Lab A", + xf: 3, + dgjsmc: "Example Lab Teacher", + kcxx: '

1-16双周,星期三第5-6节 Lab 201

', + }); + + const bundles = bundleSelectionCourses([lab, lecture, structuredClone(lab)]); + assert.equal(bundles.length, 1); + const bundle = bundles[0]!; + assert.equal(bundle.credits, 3); + assert.equal(bundle.components.length, 2); + assert.equal(bundle.components.filter((component) => component.creditBearing).length, 1); + assert.deepEqual(bundle.requiredComponentIds, ["task-lecture", "task-lab"]); + assert.deepEqual(bundle.teachingTeam, ["Example Lab Teacher", "Example Lecturer"]); + assert.deepEqual(bundle.meetings.find((meeting) => meeting.componentType === "lab")?.weeks, [2, 4, 6, 8, 10, 12, 14, 16]); + assert.deepEqual(bundle.operationTargets.map((target) => ({ + componentId: target.componentId, + courseId: target.mutationCourseId, + rwh: target.taskId, + payload: target.mutationPayloadField, + })), [ + { componentId: "task-lecture", courseId: "selection-lecture", rwh: "task-lecture", payload: "p_id" }, + { componentId: "task-lab", courseId: "selection-lab", rwh: "task-lab", payload: "p_id" }, + ]); + assert.equal(bundle.selectableWithoutGuessing, true); + assert.ok(bundle.warnings.some((warning) => /Duplicate source row/.test(warning))); +}); + +test("conflicting component credits fail closed instead of being summed or guessed", () => { + const first = normaliseCourse({ bundleId:"X", id:"id-a", rwh:"task-a", kcdm:"X", kcmc:"X", xf:2 }); + const second = normaliseCourse({ bundleId:"X", id:"id-b", rwh:"task-b", kcdm:"X", kcmc:"X", xf:3 }); + const bundle = bundleSelectionCourses([first, second])[0]!; + assert.equal(bundle.credits, undefined); + assert.equal(bundle.creditStatus, "ambiguous"); + assert.equal(bundle.components.some((component) => component.creditBearing), false); +}); + +test("raw selection records require the diagnostics-only envelope", () => { + const source = { id:"selection-id", rwh:"task-id", unknownPersonalField:"not-for-default-json" }; + const diagnostic = retainCourseSourceRecord(source); + source.unknownPersonalField = "changed"; + assert.equal(diagnostic.kind, "tis-selection-source-record"); + assert.equal(diagnostic.raw.unknownPersonalField, "not-for-default-json"); +}); diff --git a/src/test/tis-remaining-selection.test.ts b/src/test/tis-remaining-selection.test.ts index a4c221c..c79e574 100644 --- a/src/test/tis-remaining-selection.test.ts +++ b/src/test/tis-remaining-selection.test.ts @@ -7,6 +7,7 @@ import { ensureSelectionVerified, planBidUpdates, projectBidTotal, + reconcileSelectionSnapshots, revalidateSelectionWrite, selectionEndpoint, verifySelectionWrite, @@ -29,6 +30,10 @@ test("selection previews keep write payloads typed and operation-specific", () = assert.equal(enroll.endpoint, "/Xsxk/addXuanke"); assert.equal(enroll.payload.p_xktjz, "gwctjzyx"); assert.equal(enroll.payload.p_xkxs, "3"); + assert.match(enroll.clientRequestId, /^[0-9a-f-]{36}$/); + assert.deepEqual(enroll.identifierContract.readbackIdentity, ["courseId", "rwh"]); + assert.equal(enroll.idempotency.upstreamKeySupported, false); + assert.equal(enroll.idempotency.automaticRetry, "forbidden"); const cartAdd = buildSelectionPreview(CONTEXT, { operation: "cart.add", @@ -48,6 +53,38 @@ test("selection previews keep write payloads typed and operation-specific", () = assert.match(cartBid.successHeuristic, /cart bid value/i); }); +test("bounded reconciliation handles delayed visibility without repeating a mutation", () => { + const target = { + operation: "cart.add" as const, + courseId: "hex-id", + rwh: "RWH-1", + round: "bxxk", + bid: 1, + where: "cart" as const, + }; + const absent = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[], enrolled:[], round:{ xkfsdm:"bxxk" } }; + const present = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"hex-id", rwh:"RWH-1", xkxs:"1" }], enrolled:[], round:{ xkfsdm:"bxxk" } }; + const result = reconcileSelectionSnapshots([absent, absent, present], target); + assert.equal(result.status, "applied"); + assert.equal(result.automaticRetryAllowed, false); +}); + +test("bounded reconciliation distinguishes stable not-applied from conflicting readback", () => { + const target = { + operation: "bid.update" as const, + courseId: "hex-id", + rwh: "RWH-1", + round: "yixuan", + bid: 5, + where: "cart" as const, + }; + const unchanged = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"hex-id", rwh:"RWH-1", xkxs:"2" }], enrolled:[], round:{ xkfsdm:"yixuan" } }; + assert.equal(reconcileSelectionSnapshots([unchanged, structuredClone(unchanged)], target).status, "not_applied"); + + const conflicting = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"other-id", rwh:"RWH-1", xkxs:"5" }], enrolled:[], round:{ xkfsdm:"yixuan" } }; + assert.equal(reconcileSelectionSnapshots([unchanged, conflicting], target).status, "still_uncertain"); +}); + test("bid planning short-circuits when the round budget would be exceeded", () => { const plan = planBidUpdates(CONTEXT, { A: 5, B: 4 }, { where: "cart", round: "yixuan", limit: 8 }); assert.equal(plan.overLimit, true); diff --git a/src/tis/client.ts b/src/tis/client.ts index fb31bf5..443b168 100644 --- a/src/tis/client.ts +++ b/src/tis/client.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { CliError } from "../core/errors.js"; @@ -26,6 +27,7 @@ import { type EvaluationStatusFilter, } from "./remaining-evaluation.js"; import type { SelectionPreview } from "./remaining-selection.js"; +import { bundleSelectionCourses, type SelectionCourseBundle } from "./selection-bundles.js"; import type { Course, ExamRecord, @@ -82,14 +84,24 @@ export class TisClient { public async searchAvailable( semester: Semester, options: { keyword?: string; round: string; limit: number }, - ): Promise<{ courses: Course[]; total: number; enrolled: unknown[]; cart: unknown[]; round: Record }> { + ): Promise<{ + courses: Course[]; + bundles: SelectionCourseBundle[]; + total: number; + enrolled: unknown[]; + cart: unknown[]; + round: Record; + reportedAt: string; + }> { const state = await this.selectionState(semester, options); return { courses: state.courses, + bundles: bundleSelectionCourses(state.courses), total: state.total, enrolled: state.enrolled, cart: state.cart, round: state.round, + reportedAt: new Date().toISOString(), }; } @@ -293,18 +305,29 @@ export class TisClient { public async addCourse(input: { semester: Semester; courseId: string; + rwh: string; round: string; bid: number; cultivation: "1" | "2"; }): Promise { + const clientRequestId = randomUUID(); const dq = asRecord(await this.session.postForm("/Xsxk/queryXkdqXnxq", {})); - const response = asRecord( - await this.session.postForm( + let response: Record; + try { + response = asRecord(await this.session.postForm( "/Xsxk/addXuanke", buildWritePayload(input, dq), - ), - ); - return { jg: stringValue(response.jg), message: stringValue(response.message), raw: response }; + )); + } catch (error) { + throw mutationTransportError(error, { + clientRequestId, + operation: "enroll", + courseId: input.courseId, + rwh: input.rwh, + round: input.round, + }); + } + return { clientRequestId, jg: stringValue(response.jg), message: stringValue(response.message) }; } public async selectionWrite(preview: SelectionPreview): Promise { @@ -314,8 +337,21 @@ export class TisClient { endpoint: preview.endpoint, }); } - const response = asRecord(await this.session.postForm(preview.endpoint, preview.payload)); - return { jg: stringValue(response.jg), message: stringValue(response.message), raw: response }; + let response: Record; + try { + response = asRecord(await this.session.postForm(preview.endpoint, preview.payload)); + } catch (error) { + throw mutationTransportError(error, { + clientRequestId: preview.clientRequestId, + operation: preview.operation, + ...preview.exactTarget, + }); + } + return { + clientRequestId: preview.clientRequestId, + jg: stringValue(response.jg), + message: stringValue(response.message), + }; } private async fetchCatalog(semester: Semester): Promise { @@ -567,3 +603,37 @@ function selectionWriteAllowed(preview: SelectionPreview): boolean { } return false; } + +function mutationTransportError( + error: unknown, + target: Record & { clientRequestId: string; operation: string }, +): CliError { + const requestPhase = error instanceof CliError && error.details?.requestPhase === "before-send" + ? "before-send" + : "submission-may-have-started"; + if (requestPhase === "before-send") { + return new CliError( + "The selection request failed before submission; no mutation was performed.", + "TIS_SELECTION_NOT_SUBMITTED", + 4, + { + target, + upstreamCode: error instanceof CliError ? error.code : "UNKNOWN", + requestPhase, + warning: "NO_MUTATION_PERFORMED", + }, + ); + } + return new CliError( + "The selection request lost a conclusive response after submission may have started.", + "TIS_SELECTION_OUTCOME_UNKNOWN", + 5, + { + target, + upstreamCode: error instanceof CliError ? error.code : "UNKNOWN", + requestPhase, + warning: "DO_NOT_RETRY_AUTOMATICALLY", + next: "Run `sustech tis selection reconcile` for this exact courseId/rwh/round target; do not repeat the mutation.", + }, + ); +} diff --git a/src/tis/normalise.ts b/src/tis/normalise.ts index c164111..2630c12 100644 --- a/src/tis/normalise.ts +++ b/src/tis/normalise.ts @@ -1,4 +1,12 @@ -import type { Course, ExamRecord, GradeRecord, PersonalScheduleEntry, ScheduleSlot } from "./types.js"; +import type { + Course, + CourseComponentType, + CourseSelectionContract, + ExamRecord, + GradeRecord, + PersonalScheduleEntry, + ScheduleSlot, +} from "./types.js"; const DAY_CHARS = "一二三四五六日"; const DAY_NAMES = ["", "周一", "周二", "周三", "周四", "周五", "周六", "周日"]; @@ -9,6 +17,7 @@ export function normaliseCourse(raw: Record): Course { const rwh = stringValue(raw.rwh); const classGroup = stringValue(raw.kxh) || rwh.split("-").at(-1) || ""; const teachers = splitTeachers(stringValue(raw.dgjsmc)); + const selection = normaliseCourseSelectionContract(raw, rwh); return { code: stringValue(raw.kcdm), @@ -30,6 +39,36 @@ export function normaliseCourse(raw: Record): Course { language: stringValue(raw.skyymc), teachers, schedule, + ...(selection ? { selection } : {}), + }; +} + +export function normaliseCourseSelectionContract( + raw: Record, + taskId = stringValue(raw.rwh), +): CourseSelectionContract | undefined { + if (!taskId) return undefined; + const mutationId = firstString(raw, ["id", "courseId", "course_id"]); + const explicitBundleId = firstString(raw, [ + "bundleId", "bundle_id", "kczid", "KCZID", "zhrwid", "ZHRWID", "parentRwh", "PARENT_RWH", + ]); + const taskType = firstString(raw, ["componentType", "component_type", "rwlxmc", "rwlx"]); + const required = firstBoolean(raw, ["componentRequired", "component_required", "required", "sfbd"]); + const creditBearing = firstBoolean(raw, ["creditBearing", "credit_bearing", "sfxfjl"]); + return { + bundleId: explicitBundleId + ? `tis-bundle:${explicitBundleId}` + : mutationId + ? `tis-selection:${mutationId}` + : `tis-task:${taskId}`, + componentId: taskId, + componentType: componentType(taskType), + required: required ?? true, + ...(creditBearing !== undefined ? { creditBearing } : {}), + identifiers: { + task: { field: "rwh", value: taskId }, + ...(mutationId ? { mutation: { field: "courseId" as const, payloadField: "p_id" as const, value: mutationId } } : {}), + }, }; } @@ -196,6 +235,28 @@ function firstString(record: Record, keys: string[]): string { return ""; } +function firstBoolean(record: Record, keys: string[]): boolean | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + if (typeof value === "number" && (value === 0 || value === 1)) return value === 1; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "required", "是", "必修"].includes(normalized)) return true; + if (["0", "false", "no", "optional", "否", "选修"].includes(normalized)) return false; + } + } + return undefined; +} + +function componentType(value: string): CourseComponentType { + const normalized = value.toLowerCase(); + if (/实验|lab/.test(normalized)) return "lab"; + if (/习题|辅导|tutorial/.test(normalized)) return "tutorial"; + if (/讲授|理论|lecture/.test(normalized)) return "lecture"; + return normalized ? "other" : "unknown"; +} + function bitmapWeeks(bitmap: string): number[] { return [...bitmap] .map((enabled, index) => enabled === "1" ? index + 1 : undefined) diff --git a/src/tis/planning-projection.ts b/src/tis/planning-projection.ts new file mode 100644 index 0000000..5840586 --- /dev/null +++ b/src/tis/planning-projection.ts @@ -0,0 +1,167 @@ +import type { + DegreeMissingAttempt, + DegreeMissingRequiredCourse, + TisDegreeMissing, +} from "./degree-missing.js"; +import type { DegreeProgressCourse, TisDegreeProgress } from "./degree-progress.js"; +import type { PersonalScheduleEntry } from "./types.js"; + +export const PLANNING_PROJECTION_FIELDS = Object.freeze({ + degreeProgress: ["context", "summary", "creditCategories", "moduleRequirements", "moduleGaps", "sourceStatuses", "reportedAt"], + degreeCourse: ["code", "name", "group", "college", "semester", "required", "credits", "hours", "courseNature", "category", "majorTrack"], + enrollment: ["courseCode", "courseName", "rwh", "teachingTeam", "meetings"], + availability: ["bundleId", "courseCode", "courseName", "credits", "components", "teachingTeam", "meetings", "operationTargets", "reportedAt"], +} as const); + +export type GradeFreeDegreeAttempt = Omit; +export type GradeFreeMissingCourse = Omit & { + latestAttempt?: GradeFreeDegreeAttempt; + previousAttempt?: GradeFreeDegreeAttempt; +}; + +export type PlanningDegreeMissing = Omit & { + definiteMissingRequiredCourses: GradeFreeMissingCourse[]; + inProgressRequiredCourses: GradeFreeMissingCourse[]; + projection: { mode: "planning-grade-free"; fieldAllowlist: typeof PLANNING_PROJECTION_FIELDS }; +}; + +export interface PlanningEnrollment { + courseCode: string; + courseName: string; + rwh: string; + teachingTeam: string[]; + meetings: Array<{ + day?: number; + periodStart?: number; + periodEnd?: number; + weeks: number[]; + room: string; + }>; +} + +export interface PlanningSelectionRound { + code?: string; + name?: string; + bidLimit?: number; +} + +export function projectSelectionRoundForPlanning(raw: Record): PlanningSelectionRound { + const code = text(raw.xkfsdm); + const name = text(raw.lcmc ?? raw.xkfslxmc ?? raw.name); + const bidLimit = numeric(raw.jffs); + const projected = { + ...(code ? { code } : {}), + ...(name ? { name } : {}), + ...(bidLimit !== undefined ? { bidLimit } : {}), + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectDegreeProgressForPlanning( + progress: TisDegreeProgress, + options: { includeGrades?: boolean } = {}, +): TisDegreeProgress & { projection: { mode: "planning-grade-free" | "explicit-details"; fieldAllowlist: typeof PLANNING_PROJECTION_FIELDS } } { + const courses = progress.courses?.map((course) => options.includeGrades ? { ...course } : gradeFreeCourse(course)); + const projected = { + ...progress, + ...(courses ? { courses } : {}), + projection: { + mode: options.includeGrades ? "explicit-details" as const : "planning-grade-free" as const, + fieldAllowlist: PLANNING_PROJECTION_FIELDS, + }, + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectDegreeMissingForPlanning(report: TisDegreeMissing): PlanningDegreeMissing { + const projected: PlanningDegreeMissing = { + ...report, + definiteMissingRequiredCourses: report.definiteMissingRequiredCourses.map(gradeFreeMissingCourse), + inProgressRequiredCourses: report.inProgressRequiredCourses.map(gradeFreeMissingCourse), + projection: { mode:"planning-grade-free", fieldAllowlist:PLANNING_PROJECTION_FIELDS }, + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectEnrollmentForPlanning(entries: readonly PersonalScheduleEntry[]): PlanningEnrollment[] { + const grouped = new Map(); + for (const entry of entries) { + const key = `${entry.courseCode}\u0000${entry.rwh}`; + const current = grouped.get(key) ?? { + courseCode: entry.courseCode, + courseName: entry.courseName, + rwh: entry.rwh, + teachingTeam: [], + meetings: [], + }; + current.teachingTeam = unique([...current.teachingTeam, entry.teacher]); + current.meetings.push({ + ...(entry.day !== undefined ? { day:entry.day } : {}), + ...(entry.periodStart !== undefined ? { periodStart:entry.periodStart } : {}), + ...(entry.periodEnd !== undefined ? { periodEnd:entry.periodEnd } : {}), + weeks: [...entry.weeks], + room: entry.room, + }); + grouped.set(key, current); + } + const projected = [...grouped.values()].sort((left, right) => left.courseCode.localeCompare(right.courseCode) || left.rwh.localeCompare(right.rwh)); + assertPlanningProjection(projected); + return projected; +} + +export function assertPlanningProjection(value: unknown): void { + visit(value, "$", new Set()); +} + +function visit(value: unknown, path: string, seen: Set): void { + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, `${path}[${index}]`, seen)); + return; + } + for (const [key, entry] of Object.entries(value)) { + if (/^(?:password|passwd|authorization|cookie|cookies|token|accessToken|refreshToken|sid|studentId|studentNumber|raw)$/i.test(key)) { + throw new Error(`Planning projection contains forbidden field ${path}.${key}.`); + } + visit(entry, `${path}.${key}`, seen); + } +} + +function gradeFreeCourse(course: DegreeProgressCourse): DegreeProgressCourse { + const { letterGrade: _letterGrade, numericScore: _numericScore, ...projected } = course; + return projected; +} + +function gradeFreeMissingCourse(course: DegreeMissingRequiredCourse): GradeFreeMissingCourse { + const { latestAttempt, previousAttempt, ...projected } = course; + return { + ...projected, + ...(latestAttempt ? { latestAttempt:gradeFreeAttempt(latestAttempt) } : {}), + ...(previousAttempt ? { previousAttempt:gradeFreeAttempt(previousAttempt) } : {}), + }; +} + +function gradeFreeAttempt(attempt: DegreeMissingAttempt): GradeFreeDegreeAttempt { + const { letterGrade: _letterGrade, numericScore: _numericScore, ...projected } = attempt; + return projected; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} + +function text(value: unknown): string | undefined { + const result = typeof value === "string" || typeof value === "number" ? String(value).trim() : ""; + return result || undefined; +} + +function numeric(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") return undefined; + const result = Number(value); + return Number.isFinite(result) ? result : undefined; +} diff --git a/src/tis/remaining-selection.ts b/src/tis/remaining-selection.ts index acfa310..38cdf05 100644 --- a/src/tis/remaining-selection.ts +++ b/src/tis/remaining-selection.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { Semester } from "../core/semester.js"; import { CliError } from "../core/errors.js"; import { asRecord, numberValue, stringValue } from "./remaining-shared.js"; @@ -33,11 +34,13 @@ export interface SelectionContext { export interface SelectionPreviewInput { operation: SelectionOperation; courseId: string; + rwh?: string; round?: string; bid?: number; where?: SelectionBidWhere; ignoreConflicts?: boolean; ignoreZeroCapacity?: boolean; + clientRequestId?: string; } export interface SelectionVerificationStep { @@ -46,12 +49,24 @@ export interface SelectionVerificationStep { } export interface SelectionPreview { + clientRequestId: string; + exactTarget: { courseId: string; rwh?: string }; operation: SelectionOperation; endpoint: string; payload: Record; requiresExplicitConfirm: true; successHeuristic: string; verification: SelectionVerificationStep[]; + identifierContract: { + mutationInput: "courseId"; + mutationPayloadField: "p_id"; + readbackIdentity: readonly ["courseId", "rwh"]; + }; + idempotency: { + upstreamKeySupported: false; + automaticRetry: "forbidden"; + note: string; + }; } export interface BidPlan { @@ -79,7 +94,6 @@ export interface SelectionObservedEntry { bid?: number; courseIdObserved: boolean; courseIdMatches: boolean; - raw: Record; } export interface SelectionStateObservation { @@ -104,6 +118,20 @@ export interface SelectionVerificationResult { observation: SelectionStateObservation; } +export interface SelectionReconciliationResult { + schemaVersion: "1"; + status: "applied" | "not_applied" | "still_uncertain"; + target: SelectionApplyTarget; + attempts: number; + observations: Array<{ + attempt: number; + state: "desired" | "inverse" | "conflicting"; + message: string; + }>; + automaticRetryAllowed: false; + message: string; +} + export interface BidProjection { previousTotalBid: number; totalBid: number; @@ -138,12 +166,24 @@ export function buildSelectionPreview(context: SelectionContext, input: Selectio const endpoint = selectionEndpoint(input.operation, input.where); const payload = buildSelectionPayload(context, input); return { + clientRequestId: input.clientRequestId?.trim() || randomUUID(), + exactTarget: { courseId:input.courseId, ...(input.rwh?.trim() ? { rwh:input.rwh.trim() } : {}) }, operation: input.operation, endpoint, payload, requiresExplicitConfirm: true, successHeuristic: successHeuristic(input.operation, input.where), verification: verificationSteps(input.operation, input.where), + identifierContract: { + mutationInput: "courseId", + mutationPayloadField: "p_id", + readbackIdentity: ["courseId", "rwh"], + }, + idempotency: { + upstreamKeySupported: false, + automaticRetry: "forbidden", + note: "The client request ID is correlation metadata only and is not sent as an upstream idempotency key.", + }, }; } @@ -240,6 +280,7 @@ export function planBidUpdates( .map((pick) => buildSelectionPreview(context, { operation: "bid.update", courseId: pick.courseId, + ...(pick.rwh ? { rwh:pick.rwh } : {}), round: options.round, bid: pick.bid, where: options.where, @@ -400,6 +441,67 @@ export function verifySelectionWrite( return { status: "not_observed", message: "Unsupported selection operation.", observation }; } +export function reconcileSelectionSnapshots( + states: readonly SelectionStateSnapshot[], + target: SelectionApplyTarget, +): SelectionReconciliationResult { + const observations = states.map((state, index) => ({ + attempt: index + 1, + ...classifyReconciliationState(state, target), + })); + const hasConflict = observations.some((observation) => observation.state === "conflicting"); + const desired = observations.filter((observation) => observation.state === "desired").length; + const inverse = observations.filter((observation) => observation.state === "inverse").length; + const finalState = observations.at(-1)?.state; + const status: SelectionReconciliationResult["status"] = hasConflict + ? "still_uncertain" + : finalState === "desired" + ? "applied" + : desired === 0 && inverse >= 2 + ? "not_applied" + : "still_uncertain"; + return { + schemaVersion: "1", + status, + target, + attempts: states.length, + observations, + automaticRetryAllowed: false, + message: status === "applied" + ? "The exact target reached the requested final state during bounded read-back." + : status === "not_applied" + ? "Two or more consistent exact read-backs retained the pre-mutation state. Review before issuing any new mutation." + : "Read-back was missing, changed across attempts, or conflicted on exact identity; do not retry automatically.", + }; +} + +function classifyReconciliationState( + state: SelectionStateSnapshot, + target: SelectionApplyTarget, +): { state: "desired" | "inverse" | "conflicting"; message: string } { + const observation = observeSelectionState(state, target); + const candidates = [observation.cart, observation.enrolled].filter((entry) => entry !== undefined); + if (candidates.some((entry) => entry.courseIdObserved && !entry.courseIdMatches)) { + return { state: "conflicting", message: "The RWH was observed with a different course ID." }; + } + const verification = verifySelectionWrite(state, target); + if (verification.status === "confirmed") return { state: "desired", message: verification.message }; + + const exact = target.operation === "cart.add" + ? observation.cart + : target.operation === "enroll" || target.operation === "drop" + ? observation.enrolled + : target.operation === "cart.remove" + ? observation.cart + : target.where === "cart" + ? observation.cart + : observation.enrolled; + if (exact?.courseIdMatches || (!exact && (target.operation === "cart.add" || target.operation === "enroll"))) { + return { state: "inverse", message: verification.message }; + } + return { state: "conflicting", message: verification.message }; +} + export function projectBidTotal( state: SelectionStateSnapshot, picks: readonly BidPick[], @@ -534,7 +636,6 @@ function observedEntry( bid: numberValue(item.xkxs ?? item.XKXS), courseIdObserved: courseId !== undefined, courseIdMatches: courseId !== undefined && courseId === target.courseId, - raw: item, }; } return undefined; diff --git a/src/tis/remaining-text.ts b/src/tis/remaining-text.ts index 01885eb..ddb0a74 100644 --- a/src/tis/remaining-text.ts +++ b/src/tis/remaining-text.ts @@ -97,6 +97,8 @@ export function formatSelectionPreview( return [ `TIS selection preview · ${preview.operation}`, "No network request or mutation was performed.", + `Client request ID: ${preview.clientRequestId}`, + "Upstream idempotency key: unsupported; automatic retry forbidden.", ...(options.exactTarget ? [ "Exact target", diff --git a/src/tis/selection-bundles.ts b/src/tis/selection-bundles.ts new file mode 100644 index 0000000..f495504 --- /dev/null +++ b/src/tis/selection-bundles.ts @@ -0,0 +1,197 @@ +import type { Course, CourseComponentType, ScheduleSlot } from "./types.js"; + +export interface SelectionIdentifierTarget { + componentId: string; + taskId: string; + mutationCourseId?: string; + mutationPayloadField: "p_id"; + readbackIdentity: readonly ["courseId", "rwh"]; +} + +export interface SelectionCourseComponent { + componentId: string; + type: CourseComponentType; + required: boolean; + creditBearing: boolean; + taskId: string; + mutationCourseId?: string; + sectionName: string; + capacity?: number; + enrolled?: number; + teachingTeam: string[]; + meetings: ScheduleSlot[]; +} + +export interface SelectionCourseBundle { + schemaVersion: "1"; + bundleId: string; + courseCode: string; + courseName: string; + classGroup: string; + credits?: number; + creditStatus: "explicit" | "deduplicated" | "ambiguous"; + components: SelectionCourseComponent[]; + requiredComponentIds: string[]; + teachingTeam: string[]; + meetings: Array; + operationTargets: SelectionIdentifierTarget[]; + selectableWithoutGuessing: boolean; + warnings: string[]; +} + +export interface CourseDiagnosticRecord { + schemaVersion: "1"; + kind: "tis-selection-source-record"; + raw: Readonly>; +} + +/** Explicit diagnostics-only escape hatch. This envelope is never returned by a CLI command. */ +export function retainCourseSourceRecord(raw: Record): CourseDiagnosticRecord { + return { + schemaVersion: "1", + kind: "tis-selection-source-record", + raw: structuredClone(raw), + }; +} + +export function bundleSelectionCourses(courses: readonly Course[]): SelectionCourseBundle[] { + const groups = new Map(); + for (const course of courses) { + const bundleId = course.selection?.bundleId ?? `tis-task:${course.rwh}`; + const group = groups.get(bundleId) ?? []; + group.push(course); + groups.set(bundleId, group); + } + return [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([bundleId, rows]) => buildBundle(bundleId, rows)); +} + +function buildBundle(bundleId: string, rows: readonly Course[]): SelectionCourseBundle { + const warnings: string[] = []; + const byComponent = new Map(); + for (const course of [...rows].sort(componentOrder)) { + const componentId = course.selection?.componentId ?? course.rwh; + const existing = byComponent.get(componentId); + if (existing) { + warnings.push(`Duplicate source row for component ${componentId} was merged.`); + byComponent.set(componentId, { + ...existing, + teachers: unique([...existing.teachers, ...course.teachers]), + schedule: uniqueMeetings([...existing.schedule, ...course.schedule]), + }); + continue; + } + byComponent.set(componentId, course); + } + const ordered = [...byComponent.values()].sort(componentOrder); + const identities = new Set(ordered.map((course) => `${course.code.trim().toUpperCase()}\u0000${course.name.trim()}`)); + if (identities.size > 1) warnings.push("Bundle source rows disagree on course identity; manual review is required."); + + const credit = resolveCreditCarrier(ordered); + if (credit.status === "ambiguous") warnings.push("Bundle source rows disagree on credits; no credit value was projected."); + const components = ordered.map((course, index): SelectionCourseComponent => ({ + componentId: course.selection?.componentId ?? course.rwh, + type: course.selection?.componentType ?? "unknown", + required: course.selection?.required ?? true, + creditBearing: credit.index === index, + taskId: course.rwh, + ...(course.selection?.identifiers.mutation?.value + ? { mutationCourseId: course.selection.identifiers.mutation.value } + : course.id + ? { mutationCourseId: course.id } + : {}), + sectionName: course.sectionName, + ...(course.capacity !== undefined ? { capacity:course.capacity } : {}), + ...(course.enrolled !== undefined ? { enrolled:course.enrolled } : {}), + teachingTeam: [...course.teachers], + meetings: [...course.schedule], + })); + const operationTargets = components.map((component): SelectionIdentifierTarget => ({ + componentId: component.componentId, + taskId: component.taskId, + ...(component.mutationCourseId ? { mutationCourseId: component.mutationCourseId } : {}), + mutationPayloadField: "p_id", + readbackIdentity: ["courseId", "rwh"], + })); + const requiredComponents = components.filter((component) => component.required); + const selectableWithoutGuessing = requiredComponents.length > 0 + && requiredComponents.every((component) => Boolean(component.mutationCourseId && component.taskId)); + if (!selectableWithoutGuessing) warnings.push("At least one required component lacks an explicit mutation courseId/task rwh pair."); + + return { + schemaVersion: "1", + bundleId, + courseCode: ordered[0]?.code ?? "", + courseName: ordered[0]?.name ?? "", + classGroup: ordered[0]?.classGroup ?? "", + ...(credit.credits !== undefined ? { credits: credit.credits } : {}), + creditStatus: credit.status, + components, + requiredComponentIds: requiredComponents.map((component) => component.componentId), + teachingTeam: unique(components.flatMap((component) => component.teachingTeam)), + meetings: components.flatMap((component) => component.meetings.map((meeting) => ({ + ...meeting, + componentId: component.componentId, + componentType: component.type, + }))), + operationTargets, + selectableWithoutGuessing, + warnings, + }; +} + +function resolveCreditCarrier(courses: readonly Course[]): { + index?: number; + credits?: number; + status: SelectionCourseBundle["creditStatus"]; +} { + const explicit = courses + .map((course, index) => course.selection?.creditBearing === true ? index : undefined) + .filter((index): index is number => index !== undefined); + if (explicit.length === 1) return { index: explicit[0], credits: courses[explicit[0]]?.credits ?? 0, status: "explicit" }; + if (explicit.length > 1) return { status: "ambiguous" }; + + const positiveValues = uniqueNumbers(courses.map((course) => course.credits).filter((credits) => credits > 0)); + if (positiveValues.length > 1) return { status: "ambiguous" }; + const index = courses.findIndex((course) => course.credits === (positiveValues[0] ?? 0)); + return { + index: index >= 0 ? index : 0, + credits: positiveValues[0] ?? 0, + status: "deduplicated", + }; +} + +function componentOrder(left: Course, right: Course): number { + return componentRank(left.selection?.componentType ?? "unknown") + - componentRank(right.selection?.componentType ?? "unknown") + || left.rwh.localeCompare(right.rwh); +} + +function componentRank(type: CourseComponentType): number { + return ({ lecture: 0, lab: 1, tutorial: 2, other: 3, unknown: 4 })[type]; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} + +function uniqueNumbers(values: readonly number[]): number[] { + return [...new Set(values)].sort((left, right) => left - right); +} + +function uniqueMeetings(values: readonly ScheduleSlot[]): ScheduleSlot[] { + const seen = new Set(); + return values.filter((meeting) => { + const key = JSON.stringify([ + meeting.weeks, + meeting.day, + meeting.periodStart, + meeting.periodEnd, + meeting.room, + ]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/tis/types.ts b/src/tis/types.ts index 1d5ebc7..ceee032 100644 --- a/src/tis/types.ts +++ b/src/tis/types.ts @@ -27,12 +27,27 @@ export interface Course { language: string; teachers: string[]; schedule: ScheduleSlot[]; + selection?: CourseSelectionContract; +} + +export type CourseComponentType = "lecture" | "lab" | "tutorial" | "other" | "unknown"; + +export interface CourseSelectionContract { + bundleId: string; + componentId: string; + componentType: CourseComponentType; + required: boolean; + creditBearing?: boolean; + identifiers: { + task: { field: "rwh"; value: string }; + mutation?: { field: "courseId"; payloadField: "p_id"; value: string }; + }; } export interface TisWriteResult { + clientRequestId: string; jg: string; message: string; - raw: Record; } export interface PersonalScheduleEntry { From ea7108a191c8d634df9a1a5db09b8b1d354713c4 Mon Sep 17 00:00:00 2001 From: Steven777 Date: Wed, 2 Sep 2026 19:45:25 +0800 Subject: [PATCH 2/5] fix(auth): bound macOS credential status --- CHANGELOG.md | 6 +++ README.md | 4 ++ docs/AUTHENTICATION.md | 7 +++ src/core/keyring.ts | 97 +++++++++++++++++++++++++++++++--------- src/test/keyring.test.ts | 25 +++++++++++ 5 files changed, 118 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dd40a..3706383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +### Fixed + +- Made `auth status` use a metadata-only macOS Keychain lookup instead of + reading the stored password, and bounded credential-helper subprocesses to + five seconds with a structured `CREDENTIAL_STORE_TIMEOUT` status. + ## [0.10.0] - 2026-08-29 ### Added diff --git a/README.md b/README.md index 89698a6..3e657aa 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,10 @@ command-line argument, and is never written to the CLI config. If no safe backend is available, the CLI returns `CREDENTIAL_STORE_UNAVAILABLE` instead of falling back to plaintext. +On macOS, `auth status` checks Keychain item metadata without reading the +password. Credential-helper commands are bounded to five seconds and report +`CREDENTIAL_STORE_TIMEOUT` without an automatic retry. + ```bash sustech auth login --profile main sustech auth check --profile main --service bb --json diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index c8a94fa..c0b42cc 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -68,6 +68,13 @@ Linux deliberately requires a desktop D-Bus session and the distribution's `secret-tool`/`libsecret-tools` package. It does not silently fall back to a plaintext file or a session-only kernel keyring. +`auth status` does not read the stored password when checking macOS Keychain. +It uses a metadata-only `security find-generic-password` lookup without `-w`. +Credential-helper subprocesses have a five-second deadline and are never +retried automatically. If a helper exceeds that deadline, structured status +sets `reasonCode` to `CREDENTIAL_STORE_TIMEOUT`, marks the backend unavailable +for that probe, and leaves the credential and profile metadata unchanged. + ## Profiles The default profile is named `default`. Multiple accounts use explicit names: diff --git a/src/core/keyring.ts b/src/core/keyring.ts index d7f43cd..fefc7da 100644 --- a/src/core/keyring.ts +++ b/src/core/keyring.ts @@ -9,6 +9,7 @@ import { defaultConfigDirectory } from "./local-store.js"; export const DEFAULT_CREDENTIAL_PROFILE = "default"; export const SUSTECH_CREDENTIAL_SERVICE = "cn.edu.sustech.cli.cas"; export const BLACKBOARD_CALENDAR_LINK_SERVICE = "cn.edu.sustech.cli.bb-calendar-link"; +export const DEFAULT_CREDENTIAL_COMMAND_TIMEOUT_MS = 5_000; export type CredentialBackend = | "macos-keychain" @@ -18,6 +19,7 @@ export type CredentialBackend = export interface SecretStore { readonly backend: CredentialBackend; readonly persistent: true; + has?(account: string): Promise; get(account: string): Promise; set(account: string, password: string): Promise; delete(account: string): Promise; @@ -28,6 +30,7 @@ export interface CredentialStoreOptions { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; store?: SecretStore; + credentialCommandTimeoutMs?: number; } interface StoredProfile { @@ -60,6 +63,7 @@ export interface CredentialProfileStatus { persistent: boolean; storedAt?: string; profiles: string[]; + reasonCode?: "CREDENTIAL_STORE_ERROR" | "CREDENTIAL_STORE_TIMEOUT"; reason?: string; remediation?: string; } @@ -270,18 +274,20 @@ export async function getCredentialStatus( } try { - const password = await resolution.store.get(stored.account); + const credentialAvailable = resolution.store.has + ? await resolution.store.has(stored.account) + : Boolean(await resolution.store.get(stored.account)); return { profile, configured: true, - credentialAvailable: Boolean(password), + credentialAvailable, maskedSid: maskSid(stored.sid), backend: stored.backend, backendAvailable: true, persistent: true, storedAt: stored.storedAt, profiles, - ...(!password ? { reason: "The profile metadata exists, but the secret is missing from the credential store." } : {}), + ...(!credentialAvailable ? { reason: "The profile metadata exists, but the secret is missing from the credential store." } : {}), }; } catch (error) { return { @@ -294,6 +300,7 @@ export async function getCredentialStatus( persistent: true, storedAt: stored.storedAt, profiles, + reasonCode: credentialStatusReasonCode(error), reason: safeStoreReason(error), }; } @@ -433,9 +440,9 @@ async function resolveBackendForNamespace( }; } const platform = options.platform ?? process.platform; - if (platform === "darwin") return await resolveMacosKeychain(options.env, namespace); + if (platform === "darwin") return await resolveMacosKeychain(options, namespace); if (platform === "win32") return await resolveWindowsCredentialManager(namespace); - if (platform === "linux") return await resolveLinuxSecretService(options.env, namespace); + if (platform === "linux") return await resolveLinuxSecretService(options, namespace); return { backend: "unavailable", available: false, @@ -446,12 +453,13 @@ async function resolveBackendForNamespace( } async function resolveMacosKeychain( - customEnv: NodeJS.ProcessEnv | undefined, + options: CredentialStoreOptions, namespace: SecretNamespace, ): Promise { const backend = "macos-keychain" as const; const executable = "/usr/bin/security"; - const env = customEnv ?? process.env; + const env = options.env ?? process.env; + const timeoutMs = credentialCommandTimeoutMs(options); try { await access(executable, constants.X_OK); const { AsyncEntry } = await import("@napi-rs/keyring"); @@ -460,10 +468,13 @@ async function resolveMacosKeychain( const store: SecretStore = { backend, persistent: true, + async has(account) { + return await macosCredentialExists(executable, namespace.service, account, env, timeoutMs); + }, async get(account) { const password = await new AsyncEntry(namespace.service, account).getPassword() ?? undefined; if (password !== undefined) return password; - if (!await macosCredentialExists(executable, namespace.service, account, env)) return undefined; + if (!await macosCredentialExists(executable, namespace.service, account, env, timeoutMs)) return undefined; throw new Error("macOS Keychain item exists, but its secret could not be read."); }, async set(account, password) { @@ -471,7 +482,7 @@ async function resolveMacosKeychain( }, async delete(account) { const deleted = await new AsyncEntry(namespace.service, account).deleteCredential(); - if (await macosCredentialExists(executable, namespace.service, account, env)) { + if (await macosCredentialExists(executable, namespace.service, account, env, timeoutMs)) { throw new Error("macOS Keychain delete could not be verified."); } return deleted; @@ -494,10 +505,11 @@ async function macosCredentialExists( service: string, account: string, env: NodeJS.ProcessEnv, + timeoutMs: number, ): Promise { const result = await runCredentialCommand(executable, [ "find-generic-password", "-s", service, "-a", account, - ], undefined, env); + ], undefined, env, timeoutMs); if (macosItemNotFound(result)) return false; if (result.code !== 0) throw new Error("macOS Keychain metadata lookup failed."); return true; @@ -542,10 +554,11 @@ async function resolveWindowsCredentialManager(namespace: SecretNamespace): Prom } async function resolveLinuxSecretService( - customEnv: NodeJS.ProcessEnv | undefined, + options: CredentialStoreOptions, namespace: SecretNamespace, ): Promise { - const env = customEnv ?? process.env; + const env = options.env ?? process.env; + const timeoutMs = credentialCommandTimeoutMs(options); if (!env.DBUS_SESSION_BUS_ADDRESS) { return { backend: "linux-secret-service", @@ -571,7 +584,7 @@ async function resolveLinuxSecretService( async get(account) { const result = await runCredentialCommand(executable, [ "lookup", "service", namespace.service, "account", account, - ], undefined, env); + ], undefined, env, timeoutMs); if (result.code === 1 && !result.stdout.trim() && !result.stderr.trim()) return undefined; if (result.code !== 0) throw new Error("Secret Service lookup failed."); return result.stdout.replace(/\r?\n$/, "") || undefined; @@ -582,13 +595,13 @@ async function resolveLinuxSecretService( `--label=${namespace.linuxLabel} (${account.split(":", 1)[0]})`, "service", namespace.service, "account", account, - ], `${password}\n`, env); + ], `${password}\n`, env, timeoutMs); if (result.code !== 0) throw new Error("Secret Service write failed."); }, async delete(account) { const result = await runCredentialCommand(executable, [ "clear", "service", namespace.service, "account", account, - ], undefined, env); + ], undefined, env, timeoutMs); if (result.code === 1 && !result.stderr.trim()) return false; if (result.code !== 0) throw new Error("Secret Service delete failed."); return true; @@ -620,12 +633,26 @@ async function runCredentialCommand( args: string[], input: string | undefined, env: NodeJS.ProcessEnv, + timeoutMs = DEFAULT_CREDENTIAL_COMMAND_TIMEOUT_MS, ): Promise<{ code: number; stdout: string; stderr: string }> { return await new Promise((resolve, reject) => { const child = spawn(executable, args, { env, stdio: ["pipe", "pipe", "pipe"] }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let size = 0; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new CredentialCommandTimeoutError(timeoutMs)); + }, timeoutMs); + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }; child.stdout.on("data", (chunk: Buffer) => { size += chunk.length; if (size <= 64 * 1024) stdout.push(chunk); @@ -634,17 +661,39 @@ async function runCredentialCommand( size += chunk.length; if (size <= 64 * 1024) stderr.push(chunk); }); - child.once("error", reject); - child.once("close", (code) => resolve({ - code: code ?? 1, - stdout: Buffer.concat(stdout).toString("utf8"), - stderr: Buffer.concat(stderr).toString("utf8"), - })); + child.once("error", rejectOnce); + child.once("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + code: code ?? 1, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }); + }); if (input !== undefined) child.stdin.end(input, "utf8"); else child.stdin.end(); }); } +class CredentialCommandTimeoutError extends Error { + public readonly code = "CREDENTIAL_STORE_TIMEOUT"; + + public constructor(timeoutMs: number) { + super(`Credential-store command exceeded its ${timeoutMs} ms deadline.`); + this.name = "CredentialCommandTimeoutError"; + } +} + +function credentialCommandTimeoutMs(options: CredentialStoreOptions): number { + const timeoutMs = options.credentialCommandTimeoutMs ?? DEFAULT_CREDENTIAL_COMMAND_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) { + throw new Error("Credential-store command timeout must be an integer from 1 to 60000 milliseconds."); + } + return timeoutMs; +} + function requireAvailableStore(resolution: BackendResolution): SecretStore { if (resolution.store) return resolution.store; throw new CliError( @@ -731,6 +780,12 @@ function safeStoreReason(error: unknown): string { : "The operating-system credential store rejected or could not complete the request."; } +function credentialStatusReasonCode(error: unknown): "CREDENTIAL_STORE_ERROR" | "CREDENTIAL_STORE_TIMEOUT" { + return error && typeof error === "object" && "code" in error && error.code === "CREDENTIAL_STORE_TIMEOUT" + ? "CREDENTIAL_STORE_TIMEOUT" + : "CREDENTIAL_STORE_ERROR"; +} + async function readCredentialConfig(options: CredentialStoreOptions): Promise { const path = credentialConfigPath(options); let raw: string; diff --git a/src/test/keyring.test.ts b/src/test/keyring.test.ts index 832b395..321257f 100644 --- a/src/test/keyring.test.ts +++ b/src/test/keyring.test.ts @@ -20,8 +20,16 @@ class MemoryStore implements SecretStore { public readonly backend = "macos-keychain" as const; public readonly persistent = true as const; public readonly values = new Map(); + public getCalls = 0; + public hasCalls = 0; + + public async has(account: string): Promise { + this.hasCalls += 1; + return this.values.has(account); + } public async get(account: string): Promise { + this.getCalls += 1; return this.values.get(account); } @@ -60,11 +68,14 @@ test("system credential profiles keep only non-secret metadata on disk", async ( backend: "macos-keychain", }); + const getCallsBeforeStatus = store.getCalls; const status = await getCredentialStatus("personal", { configDir, store }); assert.equal(status.configured, true); assert.equal(status.credentialAvailable, true); assert.equal(status.maskedSid, "12****00"); assert.deepEqual(status.profiles, ["personal"]); + assert.equal(store.getCalls, getCallsBeforeStatus); + assert.equal(store.hasCalls, 1); const deleted = await deleteStoredCredentials("personal", { configDir, store }); assert.equal(deleted.removed, true); @@ -249,6 +260,7 @@ test("Linux Secret Service wiring performs store, lookup, and clear through secr configDir: join(root, "config"), platform: "linux" as const, env: fakeEnv, + credentialCommandTimeoutMs: 50, }; try { await mkdir(binDir); @@ -260,6 +272,9 @@ case "$1" in printf '%s' "$password" > "$FAKE_SECRET_STATE" ;; lookup) + if [ "$FAKE_SECRET_LOOKUP_HANG" = "1" ]; then + exec /bin/sleep 60 + fi if [ -s "$FAKE_SECRET_STATE" ]; then /bin/cat "$FAKE_SECRET_STATE" printf '\\n' @@ -290,6 +305,16 @@ esac assert.equal(loaded.password, "secret with spaces"); assert.equal(loaded.backend, "linux-secret-service"); + fakeEnv.FAKE_SECRET_LOOKUP_HANG = "1"; + const startedAt = Date.now(); + const timedOut = await getCredentialStatus(undefined, storeOptions); + assert.ok(Date.now() - startedAt < 2_000); + assert.equal(timedOut.credentialAvailable, false); + assert.equal(timedOut.backendAvailable, false); + assert.equal(timedOut.reasonCode, "CREDENTIAL_STORE_TIMEOUT"); + assert.match(timedOut.reason ?? "", /CREDENTIAL_STORE_TIMEOUT/); + delete fakeEnv.FAKE_SECRET_LOOKUP_HANG; + fakeEnv.FAKE_SECRET_CLEAR_ERROR = "1"; await assert.rejects( deleteStoredCredentials(undefined, storeOptions), From 8faee8575c7e13552c0bfc5189995eb90c3fc06e Mon Sep 17 00:00:00 2001 From: Grada Date: Sat, 5 Sep 2026 12:39:55 +0800 Subject: [PATCH 3/5] fix: align course instances with academic calendar --- src/cli.ts | 30 ++- src/test/academics.test.ts | 3 +- src/test/tis-remaining-calendar.test.ts | 121 ++++++++++- src/tis/normalise.ts | 2 +- src/tis/remaining-calendar.ts | 266 +++++++++++++++++++----- 5 files changed, 364 insertions(+), 58 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9c2a6d6..becc1a7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1253,8 +1253,7 @@ async function main(argv: string[]): Promise { let calendar: AcademicCalendar | undefined; let term = undefined; let calendarFailure: string | undefined; - const needsCalendar = includes.includes("holidays") - || (includes.includes("schedule") && values["week-one-monday"] === undefined && values["teaching-start"] === undefined); + const needsCalendar = includes.includes("holidays") || includes.includes("schedule"); if (needsCalendar) { try { calendar = await new CalendarClient().loadYear(calendarYearForSemester(semester), level); @@ -1286,14 +1285,28 @@ async function main(argv: string[]): Promise { try { const entries = await tis.schedule(semester); const anchor = await resolveTisIcalAnchor(values, semester, tis, term); - const scheduleEvents = scheduleIcsEvents(entries, anchor); + const scheduleEvents = scheduleIcsEvents(entries, anchor, term); events.push(...scheduleEvents); + const calendarAdjustmentUnavailable = term === undefined; + if (calendarAdjustmentUnavailable) { + omissions.push({ + source: "schedule", + code: "CALENDAR_ADJUSTMENTS_UNAVAILABLE", + message: calendarFailure + ? `Schedule dates were exported without holiday or compensatory-day adjustments: ${calendarFailure}` + : `Schedule dates were exported without holiday or compensatory-day adjustments because ${semester.value} was absent from the academic calendar.`, + }); + } sourceStatuses.schedule = { requested: true, - state: scheduleEvents.length > 0 ? "included" : "omitted", + state: scheduleEvents.length > 0 + ? (calendarAdjustmentUnavailable ? "partial" : "included") + : "omitted", eventCount: scheduleEvents.length, - omissionCount: scheduleEvents.length > 0 ? 0 : 1, - ...(scheduleEvents.length > 0 ? {} : { message: "No scheduled classes were available for the selected semester." }), + omissionCount: scheduleEvents.length > 0 ? (calendarAdjustmentUnavailable ? 1 : 0) : 1, + ...(scheduleEvents.length > 0 + ? (calendarAdjustmentUnavailable ? { message: "Academic-calendar adjustments were unavailable." } : {}) + : { message: "No scheduled classes were available for the selected semester." }), }; if (scheduleEvents.length === 0) { omissions.push({ @@ -2738,7 +2751,10 @@ async function loadLiveContext( if (scheduleResult.status === "fulfilled") { if (currentWeek > 0) { - const schedule = summariseCurrentOrNextClass(scheduleResult.value, { currentWeek, now }); + const calendarTerm = calendar.terms().find((candidate) => ( + candidate.snapshot.semester.value === semester.value + )); + const schedule = summariseCurrentOrNextClass(scheduleResult.value, { currentWeek, now, calendarTerm }); result.schedule = schedule; liveSources.tisSchedule = { state: schedule.now || schedule.next || schedule.tomorrowMorning ? "provided" : "missing", diff --git a/src/test/academics.test.ts b/src/test/academics.test.ts index c016d6d..6027dce 100644 --- a/src/test/academics.test.ts +++ b/src/test/academics.test.ts @@ -52,7 +52,7 @@ test("academic records are normalized into stable Agent-facing fields", () => { ZC: "011010", }); assert.equal(schedule.day, 2); - assert.deepEqual(schedule.weeks, [2, 3, 5]); + assert.deepEqual(schedule.weeks, [1, 2, 4]); const fallback = normalisePersonalScheduleEntry({ KEY: "xq5_jc9", @@ -63,6 +63,7 @@ test("academic records are normalized into stable Agent-facing fields", () => { assert.equal(fallback.courseName, "写作课"); assert.equal(fallback.periodStart, 9); assert.equal(fallback.periodEnd, 9); + assert.deepEqual(fallback.weeks, []); }); test("GPA summaries use credit-weighted SUSTech grade points", () => { diff --git a/src/test/tis-remaining-calendar.test.ts b/src/test/tis-remaining-calendar.test.ts index 6658a74..85fd24a 100644 --- a/src/test/tis-remaining-calendar.test.ts +++ b/src/test/tis-remaining-calendar.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; +import { CalendarTerm } from "../calendar/client.js"; import { buildIcsContent, buildScheduleIcs, @@ -10,6 +11,7 @@ import { nearestUpcomingExam, parseIsoDateTimeToUtcStamp, parseShenzhenExamTimeRange, + PERIOD_START_TIMES, scheduleOccurrences, summariseCurrentOrNextClass, teachingPeriodAtShenzhenTime, @@ -34,6 +36,29 @@ const ENTRIES: PersonalScheduleEntry[] = [ }, ]; +const FALL_2026 = new CalendarTerm({ + season: "fall", + level: "undergraduate", + humanName: "2026 Fall", + semester: { xn: "2026-2027", xq: "1", value: "2026-2027-1" }, + start: "2026-09-01", + end: "2027-01-11", + signIn: "2026-09-04", + teachingStart: "2026-09-07", + teachingEnd: "2026-12-27", + totalTeachingWeeks: 16, + midterm: { start: "2026-10-26", end: "2026-11-08", equivalentWeeks: [8, 9] }, + final: { start: "2026-12-28", end: "2027-01-08", equivalentWeeks: [17] }, + compensatories: [ + { date: "2026-09-20", weekType: "odd", workday: "Friday" }, + { date: "2026-10-10", weekType: "odd", workday: "Wednesday" }, + ], + extraBreaks: ["2026-11-20"], +}, [ + { name: "Mid-Autumn Festival", start: "2026-09-25", end: "2026-09-27" }, + { name: "National Day", start: "2026-10-01", end: "2026-10-07" }, +]); + test("week-one inference backtracks from today's week index to the semester anchor", () => { assert.equal(inferWeekOneMonday("2026-03-04", 2), "2026-02-23"); }); @@ -56,7 +81,7 @@ test("ICS export expands schedule entries into dated UTC events", () => { assert.match(ics, /LOCATION:智华楼102/); }); -test("ICS export supports the thirteenth TIS period", () => { +test("legacy ICS export keeps the historical thirteenth TIS period", () => { const lateEntry: PersonalScheduleEntry = { ...ENTRIES[0], key: "xq1_jc13", @@ -69,6 +94,76 @@ test("ICS export supports the thirteenth TIS period", () => { assert.equal(occurrence?.endUtc, "20260223T145000Z"); }); +test("2026 fall uses the current SUSTech period schedule", () => { + assert.deepEqual(PERIOD_START_TIMES, { + 1: [8, 0], + 2: [9, 0], + 3: [10, 20], + 4: [11, 20], + 5: [14, 0], + 6: [15, 0], + 7: [16, 20], + 8: [17, 20], + 9: [19, 0], + 10: [20, 0], + 11: [21, 0], + }); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T06:05:00Z"))?.periodLabel ?? "", + /P5 14:00-14:50/, + ); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T08:25:00Z"))?.periodLabel ?? "", + /P7 16:20-17:10/, + ); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T13:05:00Z"))?.periodLabel ?? "", + /P11 21:00-21:50/, + ); + assert.equal(teachingPeriodAtShenzhenTime(new Date("2026-09-08T14:05:00Z")), undefined); +}); + +test("calendar-adjusted occurrences move odd-week classes onto compensatory days", () => { + const friday: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "FRIDAY", + key: "xq5_jc5", + day: 5, + periodStart: 5, + periodEnd: 6, + weeks: [3], + }; + const wednesday: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "WEDNESDAY", + key: "xq3_jc7", + day: 3, + periodStart: 7, + periodEnd: 8, + weeks: [5], + }; + const holidayOnly: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "MONDAY", + key: "xq1_jc1", + day: 1, + periodStart: 1, + periodEnd: 2, + weeks: [5], + }; + + const occurrences = scheduleOccurrences( + [friday, wednesday, holidayOnly], + { teachingStartDate: FALL_2026.snapshot.teachingStart }, + FALL_2026, + ); + assert.deepEqual(occurrences.map((entry) => entry.date), ["2026-09-20", "2026-10-10"]); + assert.deepEqual(occurrences.map((entry) => entry.sourceDate), ["2026-09-25", "2026-10-07"]); + assert.ok(occurrences.every((entry) => entry.isCompensatory)); + assert.equal(occurrences[0]?.startUtc, "20260920T060000Z"); + assert.equal(occurrences[1]?.endUtc, "20261010T101000Z"); +}); + test("teaching-period lookup uses Asia/Shanghai wall clock boundaries", () => { const period = teachingPeriodAtShenzhenTime(new Date("2026-08-26T02:25:00Z")); assert.equal(period?.date, "2026-08-26"); @@ -134,6 +229,30 @@ test("current-or-next class summary distinguishes active and upcoming classes", assert.match(upcoming.nextDetail ?? "", /week 4 Wednesday|today|tomorrow/i); }); +test("current class summary follows compensatory dates instead of the holiday weekday", () => { + const wednesday: PersonalScheduleEntry = { + ...ENTRIES[0], + day: 3, + periodStart: 7, + periodEnd: 8, + weeks: [5], + }; + const holiday = summariseCurrentOrNextClass([wednesday], { + currentWeek: 5, + now: new Date("2026-10-07T08:30:00Z"), + calendarTerm: FALL_2026, + }); + assert.equal(holiday.now, undefined); + assert.match(holiday.nextDetail ?? "", /Saturday \(makeup for Wednesday\)/); + + const makeup = summariseCurrentOrNextClass([wednesday], { + currentWeek: 5, + now: new Date("2026-10-10T08:30:00Z"), + calendarTerm: FALL_2026, + }); + assert.match(makeup.now ?? "", /today \(makeup for Wednesday\).*16:20-18:10/); +}); + test("nearest upcoming exam keeps exact-order semantics and reports omitted malformed rows", () => { const exams: ExamRecord[] = [ { diff --git a/src/tis/normalise.ts b/src/tis/normalise.ts index c164111..a1d81e6 100644 --- a/src/tis/normalise.ts +++ b/src/tis/normalise.ts @@ -198,7 +198,7 @@ function firstString(record: Record, keys: string[]): string { function bitmapWeeks(bitmap: string): number[] { return [...bitmap] - .map((enabled, index) => enabled === "1" ? index + 1 : undefined) + .map((enabled, index) => enabled === "1" && index > 0 ? index : undefined) .filter((week): week is number => week !== undefined); } diff --git a/src/tis/remaining-calendar.ts b/src/tis/remaining-calendar.ts index 3124cda..3d86e0f 100644 --- a/src/tis/remaining-calendar.ts +++ b/src/tis/remaining-calendar.ts @@ -4,7 +4,8 @@ import { copyFile, link, lstat, open, rename, rm, stat } from "node:fs/promises" import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { CliError } from "../core/errors.js"; import { assertPathAndParentsAreNotSymlinks } from "../core/local-store.js"; -import type { Holiday } from "../calendar/types.js"; +import type { CalendarTerm } from "../calendar/client.js"; +import type { CompensatoryDay, Holiday, WeekdayName } from "../calendar/types.js"; import type { ExamRecord, PersonalScheduleEntry } from "./types.js"; import { addUtcDays, parseIsoDate, toIsoDate } from "./remaining-shared.js"; @@ -25,6 +26,9 @@ export interface IcsOccurrence { day: number; periodStart: number; periodEnd: number; + isCompensatory?: true; + sourceDate?: string; + sourceWeekday?: WeekdayName; } export interface IcsEvent { @@ -67,7 +71,21 @@ export interface TeachingPeriodWindow { } const CHINA_OFFSET_MINUTES = 8 * 60; +const CURRENT_PERIOD_SCHEDULE_START = "2026-09-07"; export const PERIOD_START_TIMES: Readonly> = { + 1: [8, 0], + 2: [9, 0], + 3: [10, 20], + 4: [11, 20], + 5: [14, 0], + 6: [15, 0], + 7: [16, 20], + 8: [17, 20], + 9: [19, 0], + 10: [20, 0], + 11: [21, 0], +}; +const LEGACY_PERIOD_START_TIMES: Readonly> = { 1: [8, 0], 2: [9, 0], 3: [10, 20], @@ -107,47 +125,134 @@ export function weekOneMondayFromAnchor(anchor: IcsAnchor): string { export function scheduleOccurrences( entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor, + calendarTerm?: CalendarTerm, ): IcsOccurrence[] { + return resolveScheduleOccurrences(entries, anchor, calendarTerm).map(({ entry: _entry, ...occurrence }) => occurrence); +} + +interface ResolvedScheduleOccurrence extends IcsOccurrence { + entry: PersonalScheduleEntry; +} + +function resolveScheduleOccurrences( + entries: readonly PersonalScheduleEntry[], + anchor: IcsAnchor, + calendarTerm?: CalendarTerm, +): ResolvedScheduleOccurrence[] { const weekOneMonday = parseIsoDate(weekOneMondayFromAnchor(anchor)); - const occurrences: IcsOccurrence[] = []; + const occurrences: ResolvedScheduleOccurrence[] = []; for (const entry of entries) { if (entry.day === undefined || entry.periodStart === undefined || entry.periodEnd === undefined) continue; for (const week of [...entry.weeks].sort((left, right) => left - right)) { const date = addUtcDays(weekOneMonday, (week - 1) * 7 + (entry.day - 1)); - const start = periodStartUtc(date, entry.periodStart); - const end = periodEndUtc(date, entry.periodEnd); - const summary = [entry.courseCode, entry.courseName].filter(Boolean).join(" ").trim() || entry.courseName || entry.courseCode || "SUSTech Class"; - const description = [entry.teacher, entry.description, `Week ${week}`].filter(Boolean).join(" | "); - occurrences.push({ - uid: `${entry.rwh || entry.key || summary}-${toIsoDate(date)}-p${entry.periodStart}@sustech-cli`, - summary, - location: entry.room || undefined, - description, - startUtc: start, - endUtc: end, - date: toIsoDate(date), - week, - day: entry.day, - periodStart: entry.periodStart, - periodEnd: entry.periodEnd, - }); + const dateText = toIsoDate(date); + if (calendarTerm && !regularClassRunsOn(calendarTerm, dateText)) continue; + occurrences.push(resolveOccurrence(entry, dateText, week)); + } + } + + if (calendarTerm) { + for (const compensatory of calendarTerm.snapshot.compensatories) { + const target = compensatoryTarget(calendarTerm, compensatory); + if (!target) continue; + const sourceDay = weekdayNumber(compensatory.workday); + for (const entry of entries) { + if ( + entry.day !== sourceDay + || entry.periodStart === undefined + || entry.periodEnd === undefined + || !entry.weeks.includes(target.week) + ) continue; + occurrences.push(resolveOccurrence(entry, compensatory.date, target.week, { + sourceDate: target.date, + sourceWeekday: compensatory.workday, + })); + } } } return occurrences.sort((left, right) => left.startUtc.localeCompare(right.startUtc) || left.uid.localeCompare(right.uid)); } +function resolveOccurrence( + entry: PersonalScheduleEntry, + date: string, + week: number, + makeup?: { sourceDate: string; sourceWeekday: WeekdayName }, +): ResolvedScheduleOccurrence { + const parsedDate = parseIsoDate(date); + const summary = [entry.courseCode, entry.courseName].filter(Boolean).join(" ").trim() + || entry.courseName + || entry.courseCode + || "SUSTech Class"; + const makeupDescription = makeup + ? `Makeup for ${makeup.sourceWeekday} ${makeup.sourceDate}` + : ""; + return { + entry, + uid: `${entry.rwh || entry.key || summary}-${date}-p${entry.periodStart}@sustech-cli`, + summary, + location: entry.room || undefined, + description: [entry.teacher, entry.description, `Week ${week}`, makeupDescription].filter(Boolean).join(" | "), + startUtc: periodStartUtc(parsedDate, entry.periodStart!), + endUtc: periodEndUtc(parsedDate, entry.periodEnd!), + date, + week, + day: entry.day!, + periodStart: entry.periodStart!, + periodEnd: entry.periodEnd!, + ...(makeup ? { + isCompensatory: true as const, + sourceDate: makeup.sourceDate, + sourceWeekday: makeup.sourceWeekday, + } : {}), + }; +} + +function regularClassRunsOn(term: CalendarTerm, date: string): boolean { + const day = term.day(date); + return date >= term.snapshot.teachingStart + && date <= term.snapshot.teachingEnd + && !day.flags.isHoliday + && !day.flags.isExtraBreak + && !day.flags.isFinal; +} + +function compensatoryTarget( + term: CalendarTerm, + compensatory: CompensatoryDay, +): { week: number; date: string } | undefined { + const parity = compensatory.weekType === "odd" ? 1 : 0; + const compensatoryDate = parseIsoDate(compensatory.date); + return Array.from({ length: term.snapshot.totalTeachingWeeks }, (_, index) => index + 1) + .filter((week) => week % 2 === parity) + .map((week) => ({ week, date: term.dateOf(week, compensatory.workday) })) + .filter((candidate) => !regularClassRunsOn(term, candidate.date)) + .sort((left, right) => ( + Math.abs(parseIsoDate(left.date).getTime() - compensatoryDate.getTime()) + - Math.abs(parseIsoDate(right.date).getTime() - compensatoryDate.getTime()) + ))[0]; +} + +function weekdayNumber(weekday: WeekdayName): number { + return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"].indexOf(weekday) + 1; +} + export function buildScheduleIcs( entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor, - options: { calendarName?: string; nowUtc?: Date } = {}, + options: { calendarName?: string; nowUtc?: Date; calendarTerm?: CalendarTerm } = {}, ): string { - return buildIcsContent(scheduleIcsEvents(entries, anchor), options); + return buildIcsContent(scheduleIcsEvents(entries, anchor, options.calendarTerm), options); } -export function scheduleIcsEvents(entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor): IcsEvent[] { - return scheduleOccurrences(entries, anchor).map((event) => ({ +export function scheduleIcsEvents( + entries: readonly PersonalScheduleEntry[], + anchor: IcsAnchor, + calendarTerm?: CalendarTerm, +): IcsEvent[] { + return scheduleOccurrences(entries, anchor, calendarTerm).map((event) => ({ uid: event.uid, summary: event.summary, description: event.description, @@ -277,39 +382,47 @@ export function parseShenzhenExamTimeRange(date: string, time: string): { startU export function summariseCurrentOrNextClass( entries: readonly PersonalScheduleEntry[], - options: { currentWeek: number; now: Date }, + options: { currentWeek: number; now: Date; calendarTerm?: CalendarTerm }, ): ScheduleReminderSummary { + if (options.calendarTerm) { + return summariseCalendarAdjustedClasses(entries, options.now, options.calendarTerm); + } + const clock = shenzhenWallClock(options.now); const currentMinute = clock.hour * 60 + clock.minute; - let active: PersonalScheduleEntry | undefined; - let next: { entry: PersonalScheduleEntry; week: number; dayOffset: number; startMinutes: number } | undefined; + let active: { entry: PersonalScheduleEntry; week: number; date: string } | undefined; + let next: { entry: PersonalScheduleEntry; week: number; dayOffset: number; startMinutes: number; date: string } | undefined; for (const entry of entries) { if (entry.day === undefined || entry.periodStart === undefined || entry.periodEnd === undefined) continue; - const startSlot = PERIOD_START_TIMES[entry.periodStart]; - const endSlot = PERIOD_START_TIMES[entry.periodEnd]; - if (!startSlot || !endSlot) continue; - const startMinutes = startSlot[0] * 60 + startSlot[1]; - const endMinutes = endSlot[0] * 60 + endSlot[1] + PERIOD_DURATION_MINUTES; for (const week of [...entry.weeks].sort((left, right) => left - right)) { if (week < options.currentWeek) continue; const dayOffset = (week - options.currentWeek) * 7 + (entry.day - clock.weekday); if (dayOffset < 0) continue; + const date = toIsoDate(addUtcDays(parseIsoDate(clock.date), dayOffset)); + const periods = periodStartTimesForDate(date); + const startSlot = periods[entry.periodStart]; + const endSlot = periods[entry.periodEnd]; + if (!startSlot || !endSlot) continue; + const startMinutes = startSlot[0] * 60 + startSlot[1]; + const endMinutes = endSlot[0] * 60 + endSlot[1] + PERIOD_DURATION_MINUTES; if (dayOffset === 0 && startMinutes <= currentMinute && currentMinute < endMinutes) { - if (!active || startMinutes < periodStartMinutes(active.periodStart ?? 99)) active = entry; + if (!active || startMinutes < periodStartMinutes(active.entry.periodStart ?? 99, active.date)) { + active = { entry, week, date }; + } continue; } if (dayOffset === 0 && startMinutes <= currentMinute) continue; if (!next || dayOffset < next.dayOffset || (dayOffset === next.dayOffset && startMinutes < next.startMinutes)) { - next = { entry, week, dayOffset, startMinutes }; + next = { entry, week, dayOffset, startMinutes, date }; } break; } } - if (active) return { now: formatScheduleEntryLabel(active, options.currentWeek) }; + if (active) return { now: formatScheduleEntryLabel(active.entry, active.week, active.date) }; if (next) { - const detail = formatUpcomingScheduleEntryDetail(next.entry, next.week, next.dayOffset); + const detail = formatUpcomingScheduleEntryDetail(next.entry, next.week, next.dayOffset, next.date); return { next: formatScheduleEntryTitle(next.entry), nextDetail: detail, @@ -319,6 +432,44 @@ export function summariseCurrentOrNextClass( return {}; } +function summariseCalendarAdjustedClasses( + entries: readonly PersonalScheduleEntry[], + now: Date, + calendarTerm: CalendarTerm, +): ScheduleReminderSummary { + const clock = shenzhenWallClock(now); + const nowStamp = formatUtc(now); + const occurrences = resolveScheduleOccurrences( + entries, + { teachingStartDate: calendarTerm.snapshot.teachingStart }, + calendarTerm, + ); + const active = occurrences.find((occurrence) => occurrence.startUtc <= nowStamp && nowStamp < occurrence.endUtc); + if (active) { + return { + now: formatScheduleEntryLabel(active.entry, active.week, active.date, active.sourceWeekday), + }; + } + + const next = occurrences.find((occurrence) => occurrence.startUtc > nowStamp); + if (!next) return {}; + const dayOffset = Math.round( + (parseIsoDate(next.date).getTime() - parseIsoDate(clock.date).getTime()) / (24 * 60 * 60 * 1000), + ); + const detail = formatUpcomingScheduleEntryDetail( + next.entry, + next.week, + dayOffset, + next.date, + next.sourceWeekday, + ); + return { + next: formatScheduleEntryTitle(next.entry), + nextDetail: detail, + ...(dayOffset === 1 ? { tomorrowMorning: `${formatScheduleEntryTitle(next.entry)} — ${detail}` } : {}), + }; +} + export function nearestUpcomingExam( exams: readonly ExamRecord[], options: { now: Date }, @@ -407,13 +558,14 @@ export function teachingPeriodAtShenzhenTime(value: Date): TeachingPeriodWindow const wallClockDate = new Date(Date.UTC(year, month - 1, day)); const weekday = wallClockDate.getUTCDay() === 0 ? 7 : wallClockDate.getUTCDay(); const minutesSinceMidnight = hour * 60 + minute; - for (const [periodText, [startHour, startMinute]] of Object.entries(PERIOD_START_TIMES)) { + const date = `${partValue(parts, "year")}-${partValue(parts, "month")}-${partValue(parts, "day")}`; + for (const [periodText, [startHour, startMinute]] of Object.entries(periodStartTimesForDate(date))) { const periodStart = Number(periodText); const startTotal = startHour * 60 + startMinute; const endTotal = startTotal + PERIOD_DURATION_MINUTES; if (minutesSinceMidnight < startTotal || minutesSinceMidnight >= endTotal) continue; return { - date: `${partValue(parts, "year")}-${partValue(parts, "month")}-${partValue(parts, "day")}`, + date, time: `${partValue(parts, "hour")}:${partValue(parts, "minute")}`, weekday, periodStart, @@ -425,13 +577,13 @@ export function teachingPeriodAtShenzhenTime(value: Date): TeachingPeriodWindow } function periodStartUtc(date: Date, period: number): string { - const slot = PERIOD_START_TIMES[period]; + const slot = periodStartTimesForDate(toIsoDate(date))[period]; if (!slot) throw new CliError("ICS export encountered an unsupported class period.", "UNSUPPORTED_PERIOD", 2, { period }); return formatUtc(new Date(chinaLocalUtcMillis(date, slot[0], slot[1]))); } function periodEndUtc(date: Date, period: number): string { - const slot = PERIOD_START_TIMES[period]; + const slot = periodStartTimesForDate(toIsoDate(date))[period]; if (!slot) throw new CliError("ICS export encountered an unsupported class period.", "UNSUPPORTED_PERIOD", 2, { period }); return formatUtc(new Date(chinaLocalUtcMillis(date, slot[0], slot[1]) + PERIOD_DURATION_MINUTES * 60 * 1000)); } @@ -503,20 +655,34 @@ function formatScheduleEntryTitle(entry: PersonalScheduleEntry): string { return [entry.courseCode, entry.courseName || entry.description || entry.descriptionEn || "Unnamed course"].filter(Boolean).join(" ").trim(); } -function formatScheduleEntryLabel(entry: PersonalScheduleEntry, currentWeek: number): string { - return `${formatScheduleEntryTitle(entry)} — ${formatUpcomingScheduleEntryDetail(entry, currentWeek, 0)}`; +function formatScheduleEntryLabel( + entry: PersonalScheduleEntry, + currentWeek: number, + date: string, + sourceWeekday?: WeekdayName, +): string { + return `${formatScheduleEntryTitle(entry)} — ${formatUpcomingScheduleEntryDetail(entry, currentWeek, 0, date, sourceWeekday)}`; } -function formatUpcomingScheduleEntryDetail(entry: PersonalScheduleEntry, week: number, dayOffset: number): string { - const start = PERIOD_START_TIMES[entry.periodStart ?? 0]; - const end = PERIOD_START_TIMES[entry.periodEnd ?? 0]; +function formatUpcomingScheduleEntryDetail( + entry: PersonalScheduleEntry, + week: number, + dayOffset: number, + date: string, + sourceWeekday?: WeekdayName, +): string { + const periods = periodStartTimesForDate(date); + const start = periods[entry.periodStart ?? 0]; + const end = periods[entry.periodEnd ?? 0]; const startText = start ? `${String(start[0]).padStart(2, "0")}:${String(start[1]).padStart(2, "0")}` : `P${entry.periodStart ?? "?"}`; const endTotal = end ? end[0] * 60 + end[1] + PERIOD_DURATION_MINUTES : undefined; const endText = endTotal === undefined ? `P${entry.periodEnd ?? "?"}` : formatPeriodEnd(endTotal); - const weekdayLabel = weekdayName(entry.day ?? 0); + const parsedDate = parseIsoDate(date); + const actualWeekday = parsedDate.getUTCDay() === 0 ? 7 : parsedDate.getUTCDay(); + const weekdayLabel = weekdayName(actualWeekday); const dayLabel = dayOffset === 0 ? "today" : dayOffset === 1 ? "tomorrow" : `week ${week} ${weekdayLabel}`; return [ - dayLabel, + `${dayLabel}${sourceWeekday ? ` (makeup for ${sourceWeekday})` : ""}`, `${startText}-${endText}`, entry.room, entry.teacher, @@ -527,11 +693,15 @@ function weekdayName(day: number): string { return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][day - 1] ?? `day ${day}`; } -function periodStartMinutes(period: number): number { - const slot = PERIOD_START_TIMES[period]; +function periodStartMinutes(period: number, date: string): number { + const slot = periodStartTimesForDate(date)[period]; return slot ? slot[0] * 60 + slot[1] : Number.POSITIVE_INFINITY; } +function periodStartTimesForDate(date: string): Readonly> { + return date >= CURRENT_PERIOD_SCHEDULE_START ? PERIOD_START_TIMES : LEGACY_PERIOD_START_TIMES; +} + async function inspectIcsDestination(destination: string, overwrite: boolean): Promise<{ destination: string; existed: boolean }> { const absolute = resolvePath(destination); await assertPathAndParentsAreNotSymlinks(absolute); From 1e39512593be318485971ca1473d61052461245c Mon Sep 17 00:00:00 2001 From: Grada Date: Sat, 5 Sep 2026 13:29:41 +0800 Subject: [PATCH 4/5] test: restrict short credential deadline to timeout fixture --- src/test/keyring.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/keyring.test.ts b/src/test/keyring.test.ts index 321257f..d47997f 100644 --- a/src/test/keyring.test.ts +++ b/src/test/keyring.test.ts @@ -260,7 +260,6 @@ test("Linux Secret Service wiring performs store, lookup, and clear through secr configDir: join(root, "config"), platform: "linux" as const, env: fakeEnv, - credentialCommandTimeoutMs: 50, }; try { await mkdir(binDir); @@ -307,7 +306,7 @@ esac fakeEnv.FAKE_SECRET_LOOKUP_HANG = "1"; const startedAt = Date.now(); - const timedOut = await getCredentialStatus(undefined, storeOptions); + const timedOut = await getCredentialStatus(undefined, { ...storeOptions, credentialCommandTimeoutMs: 50 }); assert.ok(Date.now() - startedAt < 2_000); assert.equal(timedOut.credentialAvailable, false); assert.equal(timedOut.backendAvailable, false); From 00b7fb8d182e211c960e3dd35df4f195ab91ca3a Mon Sep 17 00:00:00 2001 From: Apryle Wu Date: Mon, 7 Sep 2026 12:38:53 +0800 Subject: [PATCH 5/5] fix(tis): address selection reconciliation and planning review gaps --- docs/SELECTION_CONTRACTS.md | 4 ++-- src/test/client.test.ts | 9 +++++++- src/test/planning-projection.test.ts | 17 ++++++++++++++ src/test/selection-bundles.test.ts | 15 ++++++++++++ src/test/tis-remaining-selection.test.ts | 18 +++++++++++++++ src/tis/client.ts | 4 ++++ src/tis/normalise.ts | 18 +++++++++++---- src/tis/remaining-selection.ts | 29 ++++++++++++++++++++++-- src/tis/selection-bundles.ts | 16 ++++++++++--- 9 files changed, 117 insertions(+), 13 deletions(-) diff --git a/docs/SELECTION_CONTRACTS.md b/docs/SELECTION_CONTRACTS.md index da4f4f0..4d0dc8b 100644 --- a/docs/SELECTION_CONTRACTS.md +++ b/docs/SELECTION_CONTRACTS.md @@ -11,7 +11,7 @@ The selection surface separates catalog rows, selectable bundles, mutation ident - `credits` and `creditStatus`: equal repeated component credits are counted once. Conflicting component credits produce `creditStatus: "ambiguous"` and omit `credits` instead of guessing or summing. - `teachingTeam` and `meetings`: unions across all components, retaining parity-week schedules. - `operationTargets`: exact component-level mutation `courseId`, task `rwh`, payload field, and read-back identity. -- `selectableWithoutGuessing`: true only when every required component has the explicit identifier pair needed for mutation and verification. +- `selectableWithoutGuessing`: true only when every required component has the explicit identifier pair needed for mutation and verification, and source course identity and credit evidence do not conflict. Duplicate source rows for the same component are merged and reported in `warnings`. Default CLI output never contains the upstream selection envelope, enrolled/cart raw rows, credentials, cookies, tokens, or unrelated student fields. `retainCourseSourceRecord` is a library-level diagnostics-only escape hatch and is not called by CLI commands. @@ -44,7 +44,7 @@ Reconciliation performs two to five bounded read-only queries and reports: - `applied`: the final bounded observation reached the requested exact state; - `not_applied`: at least two consistent exact observations retained the inverse state and no desired/conflicting observation appeared; -- `still_uncertain`: a query failed, identifiers conflicted, observations regressed, or evidence remained incomplete. +- `still_uncertain`: a query failed, identifiers conflicted, round metadata was missing or mismatched, observations regressed, or evidence remained incomplete (including a missing bid value). None of these states authorizes an automatic mutation retry. `not_applied` means a human or higher-level workflow may review a new preview; it does not reuse the uncertain request. diff --git a/src/test/client.test.ts b/src/test/client.test.ts index 027fafe..d4eddc3 100644 --- a/src/test/client.test.ts +++ b/src/test/client.test.ts @@ -67,6 +67,9 @@ test("selection mutation transport failures distinguish known pre-send failure f operation:"cart.add", courseId:"selection-id", rwh:"task-id", + round:"bxxk", + bid:5, + where:"cart", clientRequestId:"00000000-0000-4000-8000-000000000001", }); const beforeSend = new TisClient({ @@ -94,7 +97,11 @@ test("selection mutation transport failures distinguish known pre-send failure f && error.exitCode === 5 && error.details?.warning === "DO_NOT_RETRY_AUTOMATICALLY" && (error.details.target as { clientRequestId?: string; rwh?: string })?.clientRequestId === preview.clientRequestId - && (error.details.target as { rwh?: string })?.rwh === "task-id", + && (error.details.target as { rwh?: string })?.rwh === "task-id" + && (error.details.target as { round?: string })?.round === "bxxk" + && (error.details.target as { bid?: number })?.bid === 5 + && (error.details.target as { semester?: string })?.semester === SEMESTER.value + && (error.details.target as { where?: string })?.where === "cart", ); }); diff --git a/src/test/planning-projection.test.ts b/src/test/planning-projection.test.ts index b504c87..d37f8ae 100644 --- a/src/test/planning-projection.test.ts +++ b/src/test/planning-projection.test.ts @@ -9,6 +9,23 @@ import { } from "../tis/planning-projection.js"; import type { TisDegreeMissing } from "../tis/degree-missing.js"; import type { TisDegreeProgress } from "../tis/degree-progress.js"; +import { normalisePersonalScheduleEntry } from "../tis/normalise.js"; + +test("SKSJ-only enrollment retains teaching and meeting fields through the planning projection", () => { + const source = { + KCDM: "CS101", RWH: "task-1", KEY: "xq3_jc7", + SKSJ: "Synthetic course\n[Example Teacher]\n[Section A]\n[1-3周][Room 101][7-8节]", + }; + const result = projectEnrollmentForPlanning([normalisePersonalScheduleEntry(source)])[0]!; + assert.deepEqual(result.teachingTeam, ["Example Teacher"]); + assert.deepEqual(result.meetings, [{ day: 3, periodStart: 7, periodEnd: 8, weeks: [1, 2, 3], room: "Room 101" }]); + assert.equal("description" in result, false); + const explicit = normalisePersonalScheduleEntry({ ...source, SKJS: "Explicit Teacher", SKDD: "Room 202", ZC: "00101", KSJC: 1, JSJC: 2 }); + assert.equal(explicit.teacher, "Explicit Teacher"); + assert.equal(explicit.room, "Room 202"); + assert.deepEqual(explicit.weeks, [2, 4]); + assert.equal(explicit.periodEnd, 2); +}); test("planning degree projections are grade-free unless details were explicit", () => { const progress = { diff --git a/src/test/selection-bundles.test.ts b/src/test/selection-bundles.test.ts index ac8a95b..064765a 100644 --- a/src/test/selection-bundles.test.ts +++ b/src/test/selection-bundles.test.ts @@ -71,3 +71,18 @@ test("raw selection records require the diagnostics-only envelope", () => { assert.equal(diagnostic.kind, "tis-selection-source-record"); assert.equal(diagnostic.raw.unknownPersonalField, "not-for-default-json"); }); + +test("duplicate-component credit conflicts remain ambiguous in either source order", () => { + for (const creditBearing of [undefined, true]) { + const first = normaliseCourse({ bundleId: "X", id: "id-a", rwh: "task-a", kcdm: "X", kcmc: "X", xf: 2, creditBearing }); + const second = normaliseCourse({ bundleId: "X", id: "id-a", rwh: "task-a", kcdm: "X", kcmc: "X", xf: 3, creditBearing }); + for (const rows of [[first, second], [second, first]]) { + const bundle = bundleSelectionCourses(rows)[0]!; + assert.equal(bundle.components.length, 1); + assert.equal(bundle.credits, undefined); + assert.equal(bundle.creditStatus, "ambiguous"); + assert.equal(bundle.components.some((component) => component.creditBearing), false); + assert.equal(bundle.selectableWithoutGuessing, false); + } + } +}); diff --git a/src/test/tis-remaining-selection.test.ts b/src/test/tis-remaining-selection.test.ts index c79e574..493d609 100644 --- a/src/test/tis-remaining-selection.test.ts +++ b/src/test/tis-remaining-selection.test.ts @@ -92,6 +92,24 @@ test("bid planning short-circuits when the round budget would be exceeded", () = assert.equal(plan.previews.length, 0); }); +test("reconciliation requires matching round evidence for every operation", () => { + for (const operation of ["cart.add", "cart.remove", "enroll", "drop", "bid.update"] as const) { + const target = { operation, courseId: "hex-id", rwh: "RWH-1", round: "bxxk", bid: 5, where: "cart" as const }; + for (const round of [{ xkfsdm: "yixuan" }, {}]) { + const entries = [{ id: "hex-id", rwh: "RWH-1", xkxs: "5" }]; + const state = { cart: entries, enrolled: entries, round }; + assert.equal(reconcileSelectionSnapshots([state, structuredClone(state)], target).status, "still_uncertain"); + assert.equal(verifySelectionWrite(state, target).status, "not_observed"); + } + } +}); + +test("a missing bid value cannot establish the inverse mutation state", () => { + const target = { operation: "bid.update" as const, courseId: "hex-id", rwh: "RWH-1", round: "bxxk", bid: 5, where: "cart" as const }; + const state = { cart: [{ id: "hex-id", rwh: "RWH-1" }], enrolled: [], round: { xkfsdm: "bxxk" } }; + assert.equal(reconcileSelectionSnapshots([state, state], target).status, "still_uncertain"); +}); + test("bid planning validates per-course bids before generating previews", () => { const plan = planBidUpdates(CONTEXT, { A: 0, B: 2 }, { where: "enrolled", round: "yixuan" }); assert.deepEqual(plan.errors, ["A: bid must be >= 1"]); diff --git a/src/tis/client.ts b/src/tis/client.ts index 443b168..163a45c 100644 --- a/src/tis/client.ts +++ b/src/tis/client.ts @@ -325,6 +325,10 @@ export class TisClient { courseId: input.courseId, rwh: input.rwh, round: input.round, + bid: input.bid, + semester: input.semester.value, + cultivation: input.cultivation, + where: "enrolled", }); } return { clientRequestId, jg: stringValue(response.jg), message: stringValue(response.message) }; diff --git a/src/tis/normalise.ts b/src/tis/normalise.ts index c834ef2..f8fa257 100644 --- a/src/tis/normalise.ts +++ b/src/tis/normalise.ts @@ -109,8 +109,16 @@ export function normalisePersonalScheduleEntry(raw: Record): Pe const keyMatch = /^xq(\d+)_jc(\d+)/i.exec(key); const weekBitmap = firstString(raw, ["ZC", "zc"]); const description = firstString(raw, ["SKSJ", "sksj"]); - const periodStart = numberValue(raw.KSJC ?? raw.ksjc) ?? (keyMatch ? Number(keyMatch[2]) : undefined); - const periodEnd = numberValue(raw.JSJC ?? raw.jsjc) ?? periodStart; + const descriptionLines = description.split(/\r?\n/).map((line) => line.trim()); + const descriptionTeacher = /^\[([^\[\]]+)\]$/.exec(descriptionLines[1] ?? "")?.[1] ?? ""; + const descriptionMeeting = /\[([\d,,、\s-]+)(单|双)?周\]\s*\[([^\[\]]*)\]\s*\[(\d+)(?:-(\d+))?节\]/.exec(description); + let descriptionWeeks = descriptionMeeting ? expandWeeks(descriptionMeeting[1].replace(/[,、]/g, ",")) : []; + if (descriptionMeeting?.[2] === "单") descriptionWeeks = descriptionWeeks.filter((week) => week % 2 === 1); + if (descriptionMeeting?.[2] === "双") descriptionWeeks = descriptionWeeks.filter((week) => week % 2 === 0); + const periodStart = numberValue(raw.KSJC ?? raw.ksjc) + ?? (keyMatch ? Number(keyMatch[2]) : descriptionMeeting ? Number(descriptionMeeting[4]) : undefined); + const periodEnd = numberValue(raw.JSJC ?? raw.jsjc) + ?? (descriptionMeeting ? Number(descriptionMeeting[5] ?? descriptionMeeting[4]) : periodStart); return { rwh: firstString(raw, ["RWH", "rwh"]), key, @@ -118,14 +126,14 @@ export function normalisePersonalScheduleEntry(raw: Record): Pe courseName: firstString(raw, ["KCMC", "kcmc", "KCWZSM", "kcwzsm", "name"]) || description.split("\n")[0]?.trim() || "", - teacher: firstString(raw, ["SKJS", "DGJSMC", "dgjsmc", "teacher"]), - room: firstString(raw, ["SKDD", "JXDD", "JXCDMC", "room"]), + teacher: firstString(raw, ["SKJS", "DGJSMC", "dgjsmc", "teacher"]) || descriptionTeacher, + room: firstString(raw, ["SKDD", "JXDD", "JXCDMC", "room"]) || descriptionMeeting?.[3]?.trim() || "", description, descriptionEn: firstString(raw, ["SKSJ_EN", "sksj_en"]), ...(keyMatch ? { day: Number(keyMatch[1]) } : {}), ...(periodStart !== undefined ? { periodStart } : {}), ...(periodEnd !== undefined ? { periodEnd } : {}), - weeks: bitmapWeeks(weekBitmap), + weeks: weekBitmap ? bitmapWeeks(weekBitmap) : descriptionWeeks, }; } diff --git a/src/tis/remaining-selection.ts b/src/tis/remaining-selection.ts index 38cdf05..e3ccb0b 100644 --- a/src/tis/remaining-selection.ts +++ b/src/tis/remaining-selection.ts @@ -50,7 +50,15 @@ export interface SelectionVerificationStep { export interface SelectionPreview { clientRequestId: string; - exactTarget: { courseId: string; rwh?: string }; + exactTarget: { + courseId: string; + rwh?: string; + semester: string; + cultivation: "1" | "2"; + round: string; + bid: number; + where: SelectionBidWhere; + }; operation: SelectionOperation; endpoint: string; payload: Record; @@ -167,7 +175,15 @@ export function buildSelectionPreview(context: SelectionContext, input: Selectio const payload = buildSelectionPayload(context, input); return { clientRequestId: input.clientRequestId?.trim() || randomUUID(), - exactTarget: { courseId:input.courseId, ...(input.rwh?.trim() ? { rwh:input.rwh.trim() } : {}) }, + exactTarget: { + courseId: input.courseId, + ...(input.rwh?.trim() ? { rwh: input.rwh.trim() } : {}), + semester: context.semester.value, + cultivation: context.cultivation, + round: stringValue(payload.p_xkfsdm), + bid: input.bid ?? 1, + where: input.where ?? "enrolled", + }, operation: input.operation, endpoint, payload, @@ -394,6 +410,9 @@ export function verifySelectionWrite( target: SelectionApplyTarget, ): SelectionVerificationResult { const observation = observeSelectionState(state, target); + if (!observation.roundCode || observation.roundCode !== target.round) { + return { status: "not_observed", message: "Read-back did not establish the requested selection round.", observation }; + } if (target.operation === "cart.add") { const cart = observation.cart; if (!cart) return { status: "not_observed", message: "The exact RWH was not observed in cart after the write.", observation }; @@ -480,6 +499,9 @@ function classifyReconciliationState( target: SelectionApplyTarget, ): { state: "desired" | "inverse" | "conflicting"; message: string } { const observation = observeSelectionState(state, target); + if (!observation.roundCode || observation.roundCode !== target.round) { + return { state: "conflicting", message: "Read-back did not establish the requested selection round." }; + } const candidates = [observation.cart, observation.enrolled].filter((entry) => entry !== undefined); if (candidates.some((entry) => entry.courseIdObserved && !entry.courseIdMatches)) { return { state: "conflicting", message: "The RWH was observed with a different course ID." }; @@ -496,6 +518,9 @@ function classifyReconciliationState( : target.where === "cart" ? observation.cart : observation.enrolled; + if (target.operation === "bid.update" && exact?.bid === undefined) { + return { state: "conflicting", message: "Read-back omitted the bid value needed to establish the final state." }; + } if (exact?.courseIdMatches || (!exact && (target.operation === "cart.add" || target.operation === "enroll"))) { return { state: "inverse", message: verification.message }; } diff --git a/src/tis/selection-bundles.ts b/src/tis/selection-bundles.ts index f495504..dc4a523 100644 --- a/src/tis/selection-bundles.ts +++ b/src/tis/selection-bundles.ts @@ -70,10 +70,14 @@ export function bundleSelectionCourses(courses: readonly Course[]): SelectionCou function buildBundle(bundleId: string, rows: readonly Course[]): SelectionCourseBundle { const warnings: string[] = []; const byComponent = new Map(); + let duplicateCreditConflict = false; for (const course of [...rows].sort(componentOrder)) { const componentId = course.selection?.componentId ?? course.rwh; const existing = byComponent.get(componentId); if (existing) { + if (existing.credits !== course.credits || existing.selection?.creditBearing !== course.selection?.creditBearing) { + duplicateCreditConflict = true; + } warnings.push(`Duplicate source row for component ${componentId} was merged.`); byComponent.set(componentId, { ...existing, @@ -85,10 +89,12 @@ function buildBundle(bundleId: string, rows: readonly Course[]): SelectionCourse byComponent.set(componentId, course); } const ordered = [...byComponent.values()].sort(componentOrder); - const identities = new Set(ordered.map((course) => `${course.code.trim().toUpperCase()}\u0000${course.name.trim()}`)); + const identities = new Set(rows.map((course) => `${course.code.trim().toUpperCase()}\u0000${course.name.trim()}`)); if (identities.size > 1) warnings.push("Bundle source rows disagree on course identity; manual review is required."); - const credit = resolveCreditCarrier(ordered); + const credit: ReturnType = duplicateCreditConflict + ? { status: "ambiguous" } + : resolveCreditCarrier(ordered); if (credit.status === "ambiguous") warnings.push("Bundle source rows disagree on credits; no credit value was projected."); const components = ordered.map((course, index): SelectionCourseComponent => ({ componentId: course.selection?.componentId ?? course.rwh, @@ -116,8 +122,12 @@ function buildBundle(bundleId: string, rows: readonly Course[]): SelectionCourse })); const requiredComponents = components.filter((component) => component.required); const selectableWithoutGuessing = requiredComponents.length > 0 + && credit.status !== "ambiguous" + && identities.size === 1 && requiredComponents.every((component) => Boolean(component.mutationCourseId && component.taskId)); - if (!selectableWithoutGuessing) warnings.push("At least one required component lacks an explicit mutation courseId/task rwh pair."); + if (!requiredComponents.length || requiredComponents.some((component) => !component.mutationCourseId || !component.taskId)) { + warnings.push("At least one required component lacks an explicit mutation courseId/task rwh pair."); + } return { schemaVersion: "1",