From 0e96579975000a24f2294de8778cb61b7c199f7f Mon Sep 17 00:00:00 2001 From: alxkm Date: Sat, 26 Sep 2026 22:31:55 +0200 Subject: [PATCH] Generate the README screen from the quiz and tighten repository tooling The screen at the top of the README was a hand copy that had drifted a column out of its box, and its question count, like the badge and the first sentence, was typed in by hand. All three are now rendered by --export-readme and checked by readmeIsUpToDate. The banner diagram now lines release and acquire up in one column, so the happens-before edge reads as a single arrow. Also: Dependabot for Gradle and Actions, a link check for the answers' links into java-concurrency-patterns, a correction issue template, least-privilege permissions, cancellation and a timeout for CI. --- .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/correction.yml | 35 +++++ .github/dependabot.yml | 16 +++ .github/workflows/ci.yml | 9 ++ .github/workflows/links.yml | 31 ++++ README.md | 22 +-- src/main/java/org/alxkm/interview/Quiz.java | 136 +++++++++++++----- .../alxkm/interview/QuestionContentTest.java | 8 +- 8 files changed, 206 insertions(+), 52 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/correction.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/links.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/correction.yml b/.github/ISSUE_TEMPLATE/correction.yml new file mode 100644 index 0000000..a758539 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/correction.yml @@ -0,0 +1,35 @@ +name: Correct an answer +description: An answer is wrong, outdated for a newer JDK, or misleading +title: "[correction] " +labels: ["correction"] +body: + - type: input + id: question + attributes: + label: Which question + description: The question id, or its number in the README. + placeholder: volatile-guarantees, or 23 + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What is wrong + description: What the answer says, and what is actually true. + validations: + required: true + + - type: textarea + id: source + attributes: + label: What settles it + description: A link to the JDK source, a JEP, the javadoc or the JLS is worth more than an argument. + validations: + required: true + + - type: input + id: jdk + attributes: + label: JDK version, if it depends on one + placeholder: "changed in JDK 24 (JEP 491)" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5e74cd3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + labels: [dependencies, build] + groups: + junit: + patterns: ["org.junit*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: [dependencies, build] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7948088..3d6b808 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,18 @@ on: pull_request: branches: [ "master" ] +permissions: + contents: read + +# A new push to a pull request makes the run for the previous push pointless. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000..5d166e4 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,31 @@ +name: links + +# Most answers link to runnable examples in java-concurrency-patterns. If that repository moves a +# file, the link here breaks silently, so check them on every change and once a week regardless. +on: + push: + branches: [ "master" ] + paths: [ "**.md" ] + pull_request: + branches: [ "master" ] + paths: [ "**.md" ] + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + links: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Check links + uses: lycheeverse/lychee-action@v2 + with: + args: --no-progress --max-retries 3 './**/*.md' + fail: true + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index da187f4..d034853 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,21 @@ loud: the mechanism, the trade-off, and the follow-up the interviewer asks next. It is also a quiz you can run. One file, no dependencies, ASCII only. + ``` +----------------------------------------------------------------------------------------+ - | J A V A C O N C U R R E N C Y I N T E R V I E W | - | | - | writer |--- write x=1 ---[ release ]--------------------> | - | \ happens-before | - | reader -----------------[ acquire ]--- reads x == 1 ----> | - | | - | 166 questions across 16 topics, each answered with the reasoning, | - | not just the keyword an interviewer is listening for | + | | + | J A V A C O N C U R R E N C Y I N T E R V I E W | + | | + | writer --- write x = 1 --->[ release ] | + | | | + | | happens-before | + | v | + | reader ------------------->[ acquire ]--- reads x == 1 ---> | + | | + | 166 questions across 16 topics, each answered with the reasoning, | + | not just the keyword an interviewer is listening for | + | | +----------------------------------------------------------------------------------------+ main menu @@ -36,6 +41,7 @@ It is also a quiz you can run. One file, no dependencies, ASCII only. choose > ``` + ## Run it diff --git a/src/main/java/org/alxkm/interview/Quiz.java b/src/main/java/org/alxkm/interview/Quiz.java index 4edbfb0..eb03e24 100644 --- a/src/main/java/org/alxkm/interview/Quiz.java +++ b/src/main/java/org/alxkm/interview/Quiz.java @@ -41,6 +41,57 @@ public final class Quiz { private Quiz() { } + // ------------------------------------------------------------- screens + // + // Plain strings rather than Ui calls, because the README shows the same screen and generates it + // from here. A hand-copied screenshot drifts by a column the first time anyone edits it. + + private static final String MENU_PROMPT = "choose >"; + + /** A box whose sides line up regardless of what is in it. */ + static List boxLines(List lines) { + int inner = WIDTH - 6; + List out = new ArrayList<>(); + out.add(" +" + "-".repeat(inner + 2) + "+"); + for (String line : lines) { + String clipped = line.length() > inner ? line.substring(0, inner) : line; + out.add(" | " + clipped + " ".repeat(inner - clipped.length()) + " |"); + } + out.add(" +" + "-".repeat(inner + 2) + "+"); + return out; + } + + /** The release and the acquire share a column, so the edge between them reads as one arrow. */ + static List bannerLines(int questions, int topics) { + return List.of( + "", + " J A V A C O N C U R R E N C Y I N T E R V I E W", + "", + " writer --- write x = 1 --->[ release ]", + " |", + " | happens-before", + " v", + " reader ------------------->[ acquire ]--- reads x == 1 --->", + "", + " " + questions + " questions across " + topics + " topics, each answered with the" + + " reasoning,", + " not just the keyword an interviewer is listening for", + ""); + } + + static List menuLines(String filter) { + return List.of( + " [1] quiz one topic", + " [2] mock interview 12 mixed questions, hardest topics first", + " [3] random ten", + " [4] flashcards no options, recall it yourself", + " [5] read a topic questions and answers, no marking", + " [6] review my misses the ones you got wrong before", + " [7] progress", + " [8] level filter currently: " + filter, + " [q] quit"); + } + // ---------------------------------------------------------------- model /** Difficulty as an interviewer would pitch it, not a judgement about the reader. */ @@ -472,15 +523,8 @@ String cyan(String text) { return paint(CYAN, text); } - /** A box whose sides line up regardless of what is in it. */ void box(List lines) { - int inner = WIDTH - 6; - System.out.println(" +" + "-".repeat(inner + 2) + "+"); - for (String line : lines) { - String clipped = line.length() > inner ? line.substring(0, inner) : line; - System.out.println(" | " + clipped + " ".repeat(inner - clipped.length()) + " |"); - } - System.out.println(" +" + "-".repeat(inner + 2) + "+"); + boxLines(lines).forEach(System.out::println); } /** The header every screen starts with: where you are on the left, how far along on the right. */ @@ -500,16 +544,7 @@ String bar(int done, int total, int width) { void banner(int questions, int topics) { blank(); - box(List.of( - " J A V A C O N C U R R E N C Y I N T E R V I E W", - "", - " writer |--- write x=1 ---[ release ]-------------------->", - " \\ happens-before", - " reader -----------------[ acquire ]--- reads x == 1 ---->", - "", - " " + questions + " questions across " + topics + " topics, each answered with the" - + " reasoning,", - " not just the keyword an interviewer is listening for")); + box(bannerLines(questions, topics)); blank(); } @@ -679,18 +714,9 @@ void menu() { while (true) { ui.line(" " + ui.bold("main menu")); ui.blank(); - ui.line(" [1] quiz one topic"); - ui.line(" [2] mock interview 12 mixed questions, hardest topics first"); - ui.line(" [3] random ten"); - ui.line(" [4] flashcards no options, recall it yourself"); - ui.line(" [5] read a topic questions and answers, no marking"); - ui.line(" [6] review my misses the ones you got wrong before"); - ui.line(" [7] progress"); - ui.line(" [8] level filter currently: " - + (filter == null ? "all" : filter.label())); - ui.line(" [q] quit"); + menuLines(filter == null ? "all" : filter.label()).forEach(ui::line); ui.blank(); - String choice = ui.ask("choose >").toLowerCase(Locale.ROOT); + String choice = ui.ask(MENU_PROMPT).toLowerCase(Locale.ROOT); ui.blank(); switch (choice) { case "1" -> quizTopic(); @@ -1095,22 +1121,54 @@ private static String escapeHtml(String text) { return text.replace("&", "&").replace("<", "<").replace(">", ">"); } + private static final String BEGIN_SCREEN = ""; + private static final String END_SCREEN = ""; + + /** The main menu as a first-time reader sees it, for the top of the README. */ + static String screen(Bank bank) { + List lines = new ArrayList<>(boxLines(bannerLines(bank.questions().size(), + bank.topics().size()))); + lines.add(""); + lines.add(" main menu"); + lines.add(""); + lines.addAll(menuLines("all")); + lines.add(""); + lines.add(" " + MENU_PROMPT); + return BEGIN_SCREEN + "\n```\n" + String.join("\n", lines) + "\n```\n" + END_SCREEN; + } + + /** + * README.md with every generated part brought up to date: the screen, the catalogue, and the + * question count in the badge and the first sentence. The count sits in a line of badges, where a + * marker comment would break the paragraph, so it is matched by pattern instead. + */ + static String render(String readme, Bank bank) { + String body = replaceBetween(readme, BEGIN_SCREEN, END_SCREEN, screen(bank)); + body = replaceBetween(body, BEGIN, END, catalogue(bank)); + int count = bank.questions().size(); + return body + .replaceAll("badge/questions-\\d+-", "badge/questions-" + count + "-") + .replaceAll("(?m)^\\d+ interview questions on Java concurrency", + count + " interview questions on Java concurrency"); + } + + private static String replaceBetween(String text, String begin, String end, String generated) { + int from = text.indexOf(begin); + int to = text.indexOf(end); + if (from < 0 || to < from) { + throw new IllegalStateException("README.md is missing the " + begin + " / " + end + + " markers"); + } + return text.substring(0, from) + generated + text.substring(to + end.length()); + } + static void exportReadme(Bank bank) { Path readme = questionsDir().getParent().resolve("README.md"); - String generated = catalogue(bank); - String body; try { String existing = Files.isRegularFile(readme) ? Files.readString(readme, StandardCharsets.UTF_8) : ""; - int begin = existing.indexOf(BEGIN); - int end = existing.indexOf(END); - if (begin < 0 || end < 0) { - throw new IllegalStateException( - "README.md is missing the " + BEGIN + " / " + END + " markers"); - } - body = existing.substring(0, begin) + generated + existing.substring(end + END.length()); - Files.writeString(readme, body, StandardCharsets.UTF_8); + Files.writeString(readme, render(existing, bank), StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException("cannot write " + readme, e); } diff --git a/src/test/java/org/alxkm/interview/QuestionContentTest.java b/src/test/java/org/alxkm/interview/QuestionContentTest.java index 9fe1c57..880be9e 100644 --- a/src/test/java/org/alxkm/interview/QuestionContentTest.java +++ b/src/test/java/org/alxkm/interview/QuestionContentTest.java @@ -93,11 +93,9 @@ void optionsAreComparableInLength(Quiz.Question question) { void readmeIsUpToDate() throws IOException { Path readme = Quiz.questionsDir().getParent().resolve("README.md"); String content = Files.readString(readme, StandardCharsets.UTF_8); - String expected = Quiz.catalogue(BANK); - int begin = content.indexOf(""); - int end = content.indexOf(""); - assertTrue(begin >= 0 && end > begin, "README.md has lost its generated section markers"); - assertEquals(expected, content.substring(begin, end + "".length()), + // render() covers the screen, the catalogue and the question count, and throws if a marker + // has gone missing. + assertEquals(Quiz.render(content, BANK), content, "README.md is out of date. Run ./gradlew readme"); } }