From 7f14acb3bad42256d01db2068b60cdd9ffd9b831 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:50:20 -0600 Subject: [PATCH 1/3] ci/duplicates: Add duplicated content PR check Each fact should have one home in the docs, with other pages linking to it; copies drift out of date the moment the original changes. This adds a non-blocking PR check that comments the passages a PR adds which say the same thing as a passage elsewhere under `docs/`, paraphrased or verbatim, plus an inline comment on each added passage pointing at its duplicates. `dev/check-duplicates.mjs` splits every page into passages (paragraphs, list items, tables, fenced code blocks), normalizes them, and compares word trigrams by Jaccard similarity. Auto-generated pages and SCHEMA_SYNC blocks are skipped. Like the links and redirects checks, CI records the findings on the merge base and reports only the ones the PR adds; main has hundreds of pre-existing findings. AGENTS.md gains the "One home per fact" rules so the duplication does not grow back. Amp-Thread-ID: https://ampcode.com/threads/T-01a11326-478d-72cf-bbb7-587d16b378e9 Co-authored-by: Amp --- .github/workflows/check-duplicates.yml | 119 ++++++ AGENTS.md | 29 +- cspell-allow-list.txt | 1 + dev/check-duplicates.mjs | 523 +++++++++++++++++++++++++ 4 files changed, 666 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/check-duplicates.yml create mode 100644 dev/check-duplicates.mjs diff --git a/.github/workflows/check-duplicates.yml b/.github/workflows/check-duplicates.yml new file mode 100644 index 000000000..f2dabf7a6 --- /dev/null +++ b/.github/workflows/check-duplicates.yml @@ -0,0 +1,119 @@ +name: Check duplicated content + +# Reports passages a pull request adds that say the same thing as a passage +# elsewhere under docs/, compared with the merge base, as a summary comment +# plus an inline comment on each added passage. Duplication already on the +# base branch is ignored. Advisory: never fails the PR. + +on: + pull_request: + paths: + - 'docs/**' + - 'dev/check-duplicates.mjs' + - 'dev/sync-review-comments.sh' + - '.github/workflows/check-duplicates.yml' + +# A new push supersedes the run for the previous one +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + check-duplicates: + name: Duplicated content introduced by this PR (advisory) + runs-on: ubuntu-latest + steps: + - name: Check out pull request head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Check out merge base + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + merge_base=$(git merge-base "$BASE_SHA" HEAD) + git worktree add "$RUNNER_TEMP/base" "$merge_base" + git diff -U0 "$merge_base" HEAD -- docs > "$RUNNER_TEMP/changes.diff" + + # The script exits 1 both with findings and when it crashes. It writes + # its output in one go at the end, so a crash leaves it empty. A crash + # does not fail the check, since it says nothing about the PR; the + # comment step reports it instead of posting the empty report. + - name: Record duplicated passages already present on the base branch + run: | + node dev/check-duplicates.mjs --format json \ + --root "$RUNNER_TEMP/base" > "$RUNNER_TEMP/base-duplicates.json" || true + + - name: Find duplicated passages introduced by this PR + id: check + env: + # File links in the report open the file on the PR branch + LINK_BASE: ${{ github.event.pull_request.head.repo.html_url }}/blob/${{ github.event.pull_request.head.ref }} + run: | + if ! [ -s "$RUNNER_TEMP/base-duplicates.json" ]; then + echo "::warning::check-duplicates crashed on the base branch, so this PR was not checked" + echo "result=crashed" >> "$GITHUB_OUTPUT" + exit 0 + fi + if node dev/check-duplicates.mjs --format markdown \ + --baseline "$RUNNER_TEMP/base-duplicates.json" \ + --diff "$RUNNER_TEMP/changes.diff" \ + --review "$RUNNER_TEMP/review.json" \ + --link-base "$LINK_BASE" > "$RUNNER_TEMP/report.md"; then + echo "result=clean" >> "$GITHUB_OUTPUT" + elif [ -s "$RUNNER_TEMP/report.md" ]; then + echo "result=found" >> "$GITHUB_OUTPUT" + else + echo "::warning::check-duplicates crashed, so this PR was not checked" + echo "result=crashed" >> "$GITHUB_OUTPUT" + fi + cat "$RUNNER_TEMP/report.md" + + - name: Comment on the pull request + # Fork PRs get a read-only token; the report is still in the job log + if: github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RESULT: ${{ steps.check.outputs.result }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + marker='' + existing_comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1) + + # Comment when there is something to report, or an earlier report to resolve + case "$RESULT" in + found) + { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md" ;; + crashed) + printf '%s\n### ⚠️ The duplicated content check could not run on this revision\n\nThis is a problem with the check, not with this PR; see the [job log](%s).\n' \ + "$marker" "$RUN_URL" > "$RUNNER_TEMP/comment.md" ;; + *) + [ -n "$existing_comment" ] || exit 0 + { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md" ;; + esac + + if [ -n "$existing_comment" ]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" \ + --field body=@"$RUNNER_TEMP/comment.md" + else + gh pr comment "$PR_NUMBER" --body-file "$RUNNER_TEMP/comment.md" + fi + + - name: Point out the duplicates as review comments + # One comment per added passage, kept in sync with the findings; + # see dev/sync-review-comments.sh + if: >- + github.event.pull_request.head.repo.full_name == github.repository + && contains(fromJSON('["clean", "found"]'), steps.check.outputs.result) + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: dev/sync-review-comments.sh '`, + 'This passage says the same thing as:', + ...duplicates.map( + ({passage, similarity}) => + `- ${passageLink(passage)} (${percent(similarity)} similar)` + ), + '', + CONVENTION + ].join('\n') + })); + return {event: 'COMMENT', body: '', comments}; +} + +const FORMATTERS = { + text: formatText, + json: pairs => + JSON.stringify( + pairs.map(({similarity, a, b}) => ({ + similarity, + a: describe(a), + b: describe(b) + })), + null, + '\t' + ) + '\n', + markdown: formatMarkdown +}; + +function main() { + const format = FORMATTERS[FORMAT]; + if (!format) { + throw new Error( + `Unknown --format "${FORMAT}"; use text, json, or markdown` + ); + } + + const passages = listDocsFiles().flatMap(file => + extractPassages( + file, + fs.readFileSync(path.join(ROOT_DIR, file), 'utf-8') + ) + ); + let pairs = findDuplicatePairs(passages); + if (BASELINE_FILE) { + pairs = withoutBaseline(pairs, BASELINE_FILE); + } + if (DIFF) { + pairs = pairs.filter( + ({a, b}) => + addedLineIn(a) !== undefined || addedLineIn(b) !== undefined + ); + } + if (REVIEW_FILE) { + fs.writeFileSync( + REVIEW_FILE, + JSON.stringify(reviewRequest(pairs), null, '\t') + '\n' + ); + } + + // Not process.exit(): that can truncate stdout when it is a pipe + process.stdout.write(format(pairs)); + process.exitCode = pairs.length === 0 ? 0 : 1; +} + +main(); From f98c8578b523369c5d2b1156e42d14305945802b Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:36:09 -0600 Subject: [PATCH 2/3] check-duplicates: report edited passages that were already duplicated A PR that edits one copy of a duplicated passage now gets a comment naming the other copies, so the author can pick the home and replace the rest with links, or make the same change there. Added duplication and edited duplication are reported in separate sections with their own advice. Also strip trailing spaces from the example-hostnames bullet in AGENTS.md. Amp-Thread-ID: https://ampcode.com/threads/T-01a11326-478d-72cf-bbb7-587d16b378e9 Co-authored-by: Amp --- AGENTS.md | 36 ++++---- dev/check-duplicates.mjs | 186 ++++++++++++++++++++++++++------------- 2 files changed, 142 insertions(+), 80 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2f95eb1f8..13fd6e54d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,23 +40,23 @@ Add product names and identifiers to `cspell-allow-list.txt`, in alphabetical order; the check reports out-of-order entries. CSpell is not a project dependency: `npx cspell@10 --no-progress ` to run it locally -- **Example hostnames**: placeholder hostnames in docs are `*.example.com` - (`sourcegraph.example.com` for the reader's instance, - `github.example.com` for a code host, - `https://sourcegraph.example.com` where a whole URL is meant); +- **Example hostnames**: placeholder hostnames in docs are `*.example.com` + (`sourcegraph.example.com` for the reader's instance, + `github.example.com` for a code host, + `https://sourcegraph.example.com` where a whole URL is meant); never ``, `[hostname]`, or a `$VARIABLE` the reader has to guess. - Example URLs use `https://`, except where needed, eg. loopback, - in-cluster, proxy, and XML-namespace addresses. - In a shell snippet a `$VARIABLE` is fine only when the same snippet - exports it with an example value. + Example URLs use `https://`, except where needed, eg. loopback, + in-cluster, proxy, and XML-namespace addresses. + In a shell snippet a `$VARIABLE` is fine only when the same snippet + exports it with an example value. `.github/workflows/check-hostnames.yml` runs `dev/check-hostnames.mjs` on the lines a PR adds and comments the findings with suggested changes - (advisory, never fails the PR). - `dev/example-hostnames.json` maps each recommended hostname to the - placeholders seen in its place, - as either literal text, a `*` glob, or a `/regex/`, - matched case-insensitively with `-`, `_`, and `.` interchangeable; - add to it when the check misses one, keeping keys and lists sorted. + (advisory, never fails the PR). + `dev/example-hostnames.json` maps each recommended hostname to the + placeholders seen in its place, + as either literal text, a `*` glob, or a `/regex/`, + matched case-insensitively with `-`, `_`, and `.` interchangeable; + add to it when the check misses one, keeping keys and lists sorted. `node dev/check-hostnames.mjs` checks all of `docs/` The `🤖 Sync generated docs` PRs overwrite the `SCHEMA_SYNC_START`…`SCHEMA_SYNC_END` blocks and whole pages @@ -66,9 +66,11 @@ `git log --author=sourcegraph-buildkite -- ` shows whether a page is one - **Duplicated content**: `.github/workflows/check-duplicates.yml` runs `dev/check-duplicates.mjs` and comments the passages a PR adds that say the - same thing as a passage elsewhere under `docs/`, paraphrased or verbatim - (advisory, never fails the PR). Not part of `pnpm run check`: main has - hundreds of pre-existing findings, and CI only reports the ones a PR adds. + same thing as a passage elsewhere under `docs/`, paraphrased or verbatim, + and the passages a PR edits that were already duplicated, so the author can + pick the home and replace the other copy with a link (advisory, never fails + the PR). Not part of `pnpm run check`: main has hundreds of pre-existing + findings, and CI only reports the ones a PR adds or edits. `node dev/check-duplicates.mjs --diff <(git diff -U0 origin/main)` runs it on your branch; without `--diff` it lists every duplicate in `docs/` diff --git a/dev/check-duplicates.mjs b/dev/check-duplicates.mjs index a43028359..e6d56e714 100644 --- a/dev/check-duplicates.mjs +++ b/dev/check-duplicates.mjs @@ -13,7 +13,8 @@ * those upstream. * * The pull request workflow (.github/workflows/check-duplicates.yml) runs this - * with --baseline and --diff, so a PR hears only about duplication it adds; it + * with --baseline and --diff, so a PR hears about the duplication it adds, and + * about the other copies of a passage it edits, which now disagree with it; it * never fails the PR. main has hundreds of pre-existing findings. * * Usage: node dev/check-duplicates.mjs [options] @@ -21,10 +22,10 @@ * --threshold <0-1> Report pairs at least this similar (default: 0.5) * --min-words Skip passages with fewer words (default: 20) * --format Output as text (default), json, or markdown - * --baseline Only report pairs absent from this JSON file (produced - * by --format json on another revision). Pairs are - * matched by the file and heading of each passage, so - * editing a pre-existing duplicate does not report it + * --baseline JSON from --format json on another revision. Pairs it + * already has, matched by the file and heading of each + * passage, are reported apart as edited rather than + * added, and only when --diff touches them * --diff Unified diff, e.g. from `git diff -U0 origin/main`; * only report pairs where a passage has an added line * --link-base Markdown output links each passage to /, @@ -293,11 +294,15 @@ function pairKey({a, b}) { return [passageKey(a), passageKey(b)].sort().join('\u0000'); } -function withoutBaseline(pairs, baselineFile) { +// The pairs with `preexisting` set where the baseline already had the pair +function markPreexisting(pairs, baselineFile) { const baseline = new Set( JSON.parse(fs.readFileSync(baselineFile, 'utf-8')).map(pairKey) ); - return pairs.filter(pair => !baseline.has(pairKey(pair))); + return pairs.map(pair => ({ + ...pair, + preexisting: baseline.has(pairKey(pair)) + })); } // The first line of the passage that the diff added, or undefined @@ -358,16 +363,19 @@ function location({file, line, endLine}) { return `${file}:${line}${endLine > line ? `-${endLine}` : ''}`; } -function formatText(pairs) { - const scope = BASELINE_FILE || DIFF ? 'new ' : ''; - if (pairs.length === 0) { - return `✅ No ${scope}duplicated passages found!\n`; - } - const groups = clusters(pairs); - const lines = [ - `❌ Found ${pairs.length} ${scope}duplicated pair(s) of passages in ${groups.length} group(s):` - ]; - for (const {passages, similarity} of groups) { +// With --baseline, the pairs the change added and the ones it only edited: +// an author touching one copy should hear about the other, since the two +// now disagree, but the advice differs. Without one, every pair is "added". +function splitByBaseline(pairs) { + return { + added: pairs.filter(pair => !pair.preexisting), + edited: pairs.filter(pair => pair.preexisting) + }; +} + +function textGroups(pairs) { + const lines = []; + for (const {passages, similarity} of clusters(pairs)) { lines.push( '', `${passages.length} passages at least ${percent(similarity)} similar:` @@ -376,6 +384,29 @@ function formatText(pairs) { lines.push(` ${location(passage)}`, ` └─ ${passage.snippet}`); } } + return lines; +} + +function formatText(pairs) { + const scope = DIFF ? ' in this change' : ''; + if (pairs.length === 0) { + return `✅ No duplicated passages found${scope}!\n`; + } + const {added, edited} = splitByBaseline(pairs); + const lines = []; + if (added.length > 0) { + lines.push( + `❌ Found ${added.length} duplicated pair(s) of passages${scope} in ${clusters(added).length} group(s):`, + ...textGroups(added) + ); + } + if (edited.length > 0) { + lines.push( + ...(lines.length > 0 ? [''] : []), + `⚠️ ${edited.length} edited passage pair(s) were already duplicated in ${clusters(edited).length} group(s):`, + ...textGroups(edited) + ); + } return lines.join('\n') + '\n'; } @@ -400,32 +431,50 @@ const CONVENTION = 'cannot drift apart. Before writing a config snippet, search `docs/` for the ' + 'key and link to the existing one.'; +const EDITED_ADVICE = + 'The passage you changed is also written somewhere else, so the two copies ' + + 'now disagree. Decide which page is the home for it, and replace the other ' + + 'copy with a link; or make the same change there too.'; + +function markdownGroups(pairs) { + return clusters(pairs).flatMap(({passages, similarity}) => [ + `**${passages.length} passages, at least ${percent(similarity)} similar**`, + ...passages.map( + passage => `- ${passageLink(passage)}: ${passage.snippet}` + ), + '' + ]); +} + // Body for a pull request comment function formatMarkdown(pairs) { if (pairs.length === 0) { - return '### ✅ This revision adds no duplicated passages\n'; + return '### ✅ This revision adds no duplicated passages, and edits none\n'; } - const groups = clusters(pairs); - const lines = [ - `### ⚠️ This PR adds ${pairs.length} duplicated pair(s) of passages`, - '', - 'Only passages this PR added are listed, with the places that already say ' + - 'the same thing; duplication already on the base branch is not. ' + - 'Each added passage also has an inline comment.', - '' - ]; - for (const {passages, similarity} of groups) { + const {added, edited} = splitByBaseline(pairs); + const lines = []; + if (added.length > 0) { lines.push( - `**${passages.length} passages, at least ${percent(similarity)} similar**`, - ...passages.map( - passage => `- ${passageLink(passage)}: ${passage.snippet}` - ), + `### ⚠️ This PR adds ${added.length} duplicated pair(s) of passages`, + '', + 'Passages this PR added, with the places that already say the same ' + + 'thing. Each added passage also has an inline comment.', + '', + ...markdownGroups(added), + CONVENTION, '' ); } + if (edited.length > 0) { + lines.push( + `### ⚠️ This PR edits ${edited.length} passage pair(s) that were already duplicated`, + '', + EDITED_ADVICE, + '', + ...markdownGroups(edited) + ); + } lines.push( - CONVENTION, - '', 'Reproduce locally with `node dev/check-duplicates.mjs --diff <(git diff -U0 origin/main)`.' ); return lines.join('\n') + '\n'; @@ -435,37 +484,45 @@ function formatMarkdown(pairs) { // posted earlier to the findings still present and delete the rest const REVIEW_MARKER = '`, - 'This passage says the same thing as:', - ...duplicates.map( - ({passage, similarity}) => - `- ${passageLink(passage)} (${percent(similarity)} similar)` - ), - '', - CONVENTION - ].join('\n') - })); + const comments = [...partners].map(([passage, duplicates]) => { + const onlyEdited = duplicates.every(({preexisting}) => preexisting); + return { + path: passage.file, + line: addedLineIn(passage), + side: 'RIGHT', + body: [ + `${REVIEW_MARKER} ${duplicates.map(({passage}) => passageKey(passage)).join(', ')} -->`, + onlyEdited + ? 'This passage was already duplicated, and is now out of step with:' + : 'This passage says the same thing as:', + ...duplicates.map( + ({passage, similarity}) => + `- ${passageLink(passage)} (${percent(similarity)} similar)` + ), + '', + onlyEdited ? EDITED_ADVICE : CONVENTION + ].join('\n') + }; + }); return {event: 'COMMENT', body: '', comments}; } @@ -500,14 +557,17 @@ function main() { ); let pairs = findDuplicatePairs(passages); if (BASELINE_FILE) { - pairs = withoutBaseline(pairs, BASELINE_FILE); - } - if (DIFF) { - pairs = pairs.filter( - ({a, b}) => - addedLineIn(a) !== undefined || addedLineIn(b) !== undefined - ); + pairs = markPreexisting(pairs, BASELINE_FILE); } + // With a diff, a pre-existing pair the change edited is still reported, so + // the author hears about the other copy; without one, nothing says which + // pre-existing pairs matter, so they are all left out + pairs = DIFF + ? pairs.filter( + ({a, b}) => + addedLineIn(a) !== undefined || addedLineIn(b) !== undefined + ) + : pairs.filter(pair => !pair.preexisting); if (REVIEW_FILE) { fs.writeFileSync( REVIEW_FILE, From 008761b21aabfb60adb8616aa5082ef2cb2fafdb Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:36:29 -0600 Subject: [PATCH 3/3] test: copy a paragraph and edit a duplicated one (throwaway) Amp-Thread-ID: https://ampcode.com/threads/T-01a11326-478d-72cf-bbb7-587d16b378e9 Co-authored-by: Amp --- docs/admin/how-to/enable-experimental-feature.mdx | 3 +++ docs/code-search/features.mdx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/admin/how-to/enable-experimental-feature.mdx b/docs/admin/how-to/enable-experimental-feature.mdx index c8dfe8c0a..7129aefd9 100644 --- a/docs/admin/how-to/enable-experimental-feature.mdx +++ b/docs/admin/how-to/enable-experimental-feature.mdx @@ -26,6 +26,9 @@ This document will take you through how to add, enable, or disable an experiment 5. After changing the values in Administration > Global settings, the frontend will either restart automatically or you might be asked to restart the frontend for the changes to take effect. 6. For more information, see [Editing global settings for administrators](/admin/config/settings#editing-global-settings-for-site-admins) +After setting or changing certain values in **Site admin > Global settings** the frontend will restart automatically or +you might be asked to restart the frontend for the changes to take effect. + ## Further resources - [Sourcegraph - Configuration Settings](/admin/config/settings) diff --git a/docs/code-search/features.mdx b/docs/code-search/features.mdx index 0b39d7cb6..55a44a31f 100644 --- a/docs/code-search/features.mdx +++ b/docs/code-search/features.mdx @@ -57,7 +57,7 @@ Saved searches let you save and describe search queries so you can easily monito Search contexts help you search the code you care about on Sourcegraph. A search context represents a set of repositories at specific revisions on a Sourcegraph instance that will be targeted by search queries by default. -Every search on Sourcegraph uses a search context. Search contexts can be defined with the contexts selector shown in the search input, or entered directly in a search query. +Every search in Sourcegraph uses a search context. Search contexts can be defined with the contexts selector shown in the search input, or entered directly in a search query. See the [search contexts](/code-search/working/search-contexts) documentation to learn how to use and create search contexts.