diff --git a/editorial/production/README.md b/editorial/production/README.md index 6baa40b..908a3f1 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 24 из 358 созданных материалов. Остальные 334 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 27 из 358 созданных материалов. Остальные 331 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия @@ -19,6 +19,6 @@ - исследовательское ревью: ссылки проверены, версии и ограничения названы; - редакторское ревью: проблема в начале, нет шаблонного языка, голос соответствует году; - визуальное ревью: рисунки открываются, таблицы работают на 375px, у рисунков есть `alt` и подписи; -- `node --check`, XML-проверка диаграмм, `npm run audit:articles -- ` и production-сборка проходят. +- `node --check`, `npm run audit:draft -- scripts/upgrade-YYYY-MM.mjs`, XML-проверка диаграмм, `npm run audit:articles -- ` и production-сборка проходят. После этого рядом с партией появляется запись в `editorial/reviews/`, а изменение публикуется отдельным коммитом. Ни один скрипт не должен перегенерировать уже отревьюированный архив целиком. diff --git a/editorial/reviews/2018-09-draft.md b/editorial/reviews/2018-09-draft.md new file mode 100644 index 0000000..f70a7ca --- /dev/null +++ b/editorial/reviews/2018-09-draft.md @@ -0,0 +1,79 @@ +# Сентябрь 2018 — черновое тройное ревью «Воспроизводимое окружение Windows» + +Пакет автономный и предназначен для отдельной интеграции: + +- editorial-2018-09-practice-windows-dev-env +- editorial-2018-09-mechanism-windows-dev-env +- editorial-2018-09-field-windows-dev-env + +В этой партии созданы только: + +- web/scripts/upgrade-2018-09.mjs; +- три SVG в web/public/assets/editorial/2018/; +- этот файл ревью. + +web/data/articles.json, реестр ревизий, аудит, стандарт качества, очередь и Git не менялись. Дата ревью: 31 июля 2026 года. + +## 1. Факты и техника — пройдено + +### Граница и первичные источники + +| Утверждение в статье | Чем сверено | Оговорка в тексте | +| --- | --- | --- | +| В Windows у переменных среды есть Machine, User и Process scopes; дочерние процессы наследуют переменные родителя | [about_Environment_Variables](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables?view=powershell-5.1), [Win32 Environment Variables](https://learn.microsoft.com/en-us/windows/win32/procthread/environment-variables) | Статья не утверждает, что старое окно мгновенно получает постоянную правку; проверка идёт в новом сеансе | +| PATH задаёт каталоги поиска, а PATHEXT — исполняемые расширения | [about_Environment_Variables](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables?view=powershell-5.1) | Это не единственный слой выбора в PowerShell: отдельно названы alias, функции, cmdlet и внешние приложения | +| Get-Command -All показывает несколько одноимённых команд в порядке выбора PowerShell | [Get-Command](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/get-command?view=powershell-5.1) | where.exe не выдан за эквивалент: он помогает увидеть файлы в текущем каталоге и PATH | +| chcp показывает активную кодовую страницу; смена относится к программам, стартующим после неё | [chcp](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/chcp) | Кодовая страница консоли не приравнена к кодировке файла или настройке вывода .NET | +| JSON-снимок и дифф строятся стандартными PowerShell-командами | [ConvertTo-Json](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/convertto-json?view=powershell-5.1), [Compare-Object](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/compare-object?view=powershell-5.1) | Сравнение показывает различающиеся строки, а не доказывает причину без повторного запуска исходной команды | + +### Технический проход + +- Скрипт экспортирует три ревизии и не меняет архив при import. +- Контракт CLI проверен отдельно: --print-revisions выдал только валидный JSON из трёх объектов, без строки Usage и без stderr. +- В примерах нет команды, отключающей execution policy, антивирус, проверку сертификатов или иной механизм безопасности. +- Опасные данные ограничены заранее выбранными полями. В тексте отдельно сказано, что PATH может раскрыть имя пользователя и внутренние каталоги, поэтому JSON нужно просмотреть перед передачей. +- Поля PATH, кодовая страница, найденный кандидат и результат --version не смешаны в один «универсальный диагноз». Каждая статья называет следующий наблюдаемый шаг. +- Пути и версии в полевом разборе прямо помечены учебными. Они не приписываются реальному пользователю, команде или измерению. +- В рабочей среде нет Windows PowerShell 5.1, поэтому примеры не объявлены выполненными на другой ОС. Их синтаксис, параметры и фактические утверждения сверены с первичной документацией; перед публикацией на Windows нужен отдельный локальный прогон учебных .ps1. + +## 2. Редактура и голос М1 — пройдено + +| Ревизия | Один вопрос | Симптом и цена в начале | Освоенное умение 2018 года | Основной текст | +| --- | --- | --- | --- | --- | +| Практика | Какой минимальный снимок позволяет повторить запуск | Команда не находит инструмент или выбирает другой; риск — случайная глобальная правка и утечка переменных | PowerShell-скрипт, JSON и ручная проверка пути | 5 866 знаков без кода и источников | +| Механизм | Как PowerShell выбирает команду и где участвует кодовая страница | Версия отличается и текст нечитаем; риск — исправить не тот бинарник или повредить текст | Get-Command -All, where.exe, процессный PATH | 6 864 знака без кода и источников | +| Поле | Как найти различие между двумя машинами без переустановки | Одинаковая команда расходится; риск — стереть след случайными установками | Снимок, Compare-Object, обратимый опыт в новом сеансе | 7 343 знака без кода и источников | + +- Голос удержан на М1: инженерская заметка 2018 года идёт от наблюдаемой ошибки к короткой команде, затем к ручной проверке. +- Не добавлены поздние роли, SLO, Kubernetes, контейнерные платформы, продуктовые метрики или непроверяемые истории внедрения. +- В каждом тексте есть один рисунок с осмысленным alt и подписью, одна доступная таблица, воспроизводимый PowerShell-фрагмент, нумерованный порядок действий, ограничения и точный заголовок <h2>Проверяемые источники</h2>. +- Убраны общие вводные и обещания «универсального решения». Утверждения ограничены версией, текущим процессом, конкретной командой или учебным примером. + +### Третий редакторский проход — пройдено + +- Независимый draft-gate вернул замечание только к editorial-2018-09-field-windows-dev-env: в первых 800 знаках был сценарий, но не было явного маркера проблемы. +- Первое предложение заменено минимально: оно начинается с Симптом:, сохраняет две Windows-машины, расходящийся запуск и цену поспешной переустановки. +- Остальная структура, объём, источники, визуал и тон М1 не менялись. Повторный npm run audit:draft -- scripts/upgrade-2018-09.mjs из web/ прошёл для всех трёх slug: 8 086 / 7 690 / 8 745 знаков body; node --check scripts/upgrade-2018-09.mjs также завершился успешно. + +## 3. Визуал и выпуск — пройдено + +- windows-dev-env-snapshot-2018.svg объясняет состав минимального снимка и границу с секретными данными. +- windows-dev-env-resolution-2018.svg разделяет поиск команды и проверку кодовой страницы; это предотвращает неверный вывод, что where.exe полностью описывает PowerShell. +- windows-dev-env-diff-2018.svg показывает порядок полевого разбора: одинаковая команда → очищенные снимки → одно отличие → обратимый опыт → повтор. +- Все схемы имеют адаптивный viewBox шириной 720px, используют контрастный текст и не содержат текста, который является единственным носителем смысла: связи также видны по группам и стрелкам. +- SVG открыты через локальный Quick Look в масштабе 1200px. При первом просмотре нижняя строка схемы полевого разбора выходила за правую границу; текст сокращён, схема отрендерена повторно без обрезания. На трёх схемах проверены стрелки, контраст, кириллица и отсутствие клиппинга. + +### Выполненные команды и результат + + node --check web/scripts/upgrade-2018-09.mjs + node web/scripts/upgrade-2018-09.mjs --print-revisions + node --input-type=module -e "import('./web/scripts/upgrade-2018-09.mjs').then(({revisions}) => { if (revisions.length !== 3) process.exit(1); })" + xmllint --noout web/public/assets/editorial/2018/windows-dev-env-snapshot-2018.svg + xmllint --noout web/public/assets/editorial/2018/windows-dev-env-resolution-2018.svg + xmllint --noout web/public/assets/editorial/2018/windows-dev-env-diff-2018.svg + +- Все шесть команд завершились успешно. Отдельно проверены чистый import, точный порядок трёх slug, 5 000–15 000 знаков основного текста, 8 / 8 / 9 смысловых заголовков, по два блока кода, figure с непустым alt и подписью, таблица с thead, нумерованный маршрут, точный раздел источников и 4 / 5 / 5 внешних первичных ссылок. +- git diff --check по пяти файлам партии прошёл без пробелов и конфликтных маркеров. В статусе партии — ровно эти пять новых файлов; скрипт не импортирует и не записывает архив, реестр, аудит, стандарт, очередь или Git-историю. +- После интеграции основной редактор повторно прогнал strict audit через реестр ревизий и production build. Сборка прошла и сгенерировала 374 статические страницы. + +Статус: принят в публикационный слой 31 июля 2026 года. Ревизии накладываются только по стабильным slug, без изменения даты, автора и базового архива. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 1ac61aa..ce5e773 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -3,6 +3,7 @@ import { revisions as may2018Revisions } from '../scripts/upgrade-2018-05.mjs'; import { revisions as june2018Revisions } from '../scripts/upgrade-2018-06.mjs'; import { revisions as july2018Revisions } from '../scripts/upgrade-2018-07.mjs'; import { revisions as august2018Revisions } from '../scripts/upgrade-2018-08.mjs'; +import { revisions as september2018Revisions } from '../scripts/upgrade-2018-09.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -11,4 +12,5 @@ export const editorialRevisions = [ ...june2018Revisions, ...july2018Revisions, ...august2018Revisions, + ...september2018Revisions, ]; diff --git a/web/package.json b/web/package.json index 93f9a2d..b262844 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "admin:posts": "node ../local-admin/posts-admin.mjs", "build": "next build", "audit:articles": "node scripts/audit-quality-batch.mjs", + "audit:draft": "node scripts/audit-editorial-draft.mjs", "start": "next start" }, "dependencies": { diff --git a/web/public/assets/editorial/2018/windows-dev-env-diff-2018.svg b/web/public/assets/editorial/2018/windows-dev-env-diff-2018.svg new file mode 100644 index 0000000..32fea19 --- /dev/null +++ b/web/public/assets/editorial/2018/windows-dev-env-diff-2018.svg @@ -0,0 +1,67 @@ + + Сравнение двух Windows-окружений + Два одинаковых запуска проекта дают два очищенных снимка окружения. Сравнение выделяет различие в пути, первом кандидате бинарника или кодовой странице. Затем выполняется один обратимый опыт в новом PowerShell и повторяется исходная команда. + + + + + + + + + + + + + + + + «Работает на моей машине» → сравнимые данные + Одна исходная команда, один снимок на машину, одна проверяемая гипотеза. + + + Машина A + commit + command + snapshot A, пути очищены + + + Машина B + commit + command + snapshot B, пути очищены + + + + + + Compare-Object + PATH · кандидат · версия · консоль + + + + + Различие + старый node.exe + или кодовая страница + + + Опыт + $env:Path + только новый сеанс + + + Повтор + npm run build + исходная команда + + + + Нет изменения — проверить лог, файл или право доступа. + diff --git a/web/public/assets/editorial/2018/windows-dev-env-resolution-2018.svg b/web/public/assets/editorial/2018/windows-dev-env-resolution-2018.svg new file mode 100644 index 0000000..3202e6c --- /dev/null +++ b/web/public/assets/editorial/2018/windows-dev-env-resolution-2018.svg @@ -0,0 +1,70 @@ + + Как Windows PowerShell выбирает команду + PowerShell получает блок окружения процесса с PATH и PATHEXT. Get-Command с параметром All показывает alias, функции и приложения в порядке выбора. Where.exe отдельно показывает файлы. Кодовая страница проверяется отдельной веткой. + + + + + + + + + + + + Разрешение имени команды + Поиск бинарника и отображение текста — два разных пути. + + + PowerShell-процесс + node --version + + + Блок среды + PATH + PATHEXT + унаследован от родителя + + + Кодовая страница + chcp + OutputEncoding + читаемость вывода + + + + + + Get-Command + alias / function + cmdlet / app + порядок PowerShell + + + where.exe + node.exe + node.cmd + файлы в PATH + + + Проверка + путь + версия + текст вывода + одна гипотеза + + + + + + + diff --git a/web/public/assets/editorial/2018/windows-dev-env-snapshot-2018.svg b/web/public/assets/editorial/2018/windows-dev-env-snapshot-2018.svg new file mode 100644 index 0000000..1a0d525 --- /dev/null +++ b/web/public/assets/editorial/2018/windows-dev-env-snapshot-2018.svg @@ -0,0 +1,69 @@ + + Минимальный снимок окружения Windows + Проект запускает PowerShell. Из процесса извлекаются путь PATH, расширения PATHEXT, кандидаты команд, версии и кодовая страница. Проверенные поля сохраняются в JSON, а секреты исключаются. + + + + + + + + + + + + + + + + Минимальный снимок запуска + Не копия компьютера, а проверяемый путь команды. + + + Проект + npm run build + одна исходная команда + + + PowerShell + process scope + текущий сеанс + + + + Путь + PATH + PATHEXT + порядок папок + + + Команды + Get-Command + --version + кандидаты и вывод + + + Консоль + chcp + OutputEncoding + только наблюдение + + + + + + + environment-snapshot.json + версии, пути, кандидаты; секреты исключены + + + + diff --git a/web/scripts/audit-editorial-draft.mjs b/web/scripts/audit-editorial-draft.mjs new file mode 100644 index 0000000..e428305 --- /dev/null +++ b/web/scripts/audit-editorial-draft.mjs @@ -0,0 +1,141 @@ +import { execFile } from 'node:child_process'; +import { access, readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const execFileAsync = promisify(execFile); +const webRoot = join(fileURLToPath(new URL('..', import.meta.url))); +const scriptArgument = process.argv[2]; + +if (!scriptArgument) { + throw new Error('Usage: node scripts/audit-editorial-draft.mjs '); +} + +const scriptPath = isAbsolute(scriptArgument) + ? scriptArgument + : resolve(process.cwd(), scriptArgument); +const archive = JSON.parse(await readFile(join(webRoot, 'data', 'articles.json'), 'utf8')); +const archiveBySlug = new Map(archive.map((article) => [article.slug, article])); +const genericPhrases = [ + 'У этой модели нет магической силы', + 'Материалы для проверки', + 'Если держать этот порядок, решение остаётся понятным', + 'В современном мире', + 'очень важно', + 'следует отметить', + 'просто нужно', + 'нужно понимать, что', +]; + +function count(content, expression) { + return (content.match(expression) || []).length; +} + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:quot|amp|lt|gt|#039);/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function bodyText(content) { + return plainText( + content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, ''), + ); +} + +const cli = await execFileAsync(process.execPath, [scriptPath, '--print-revisions'], { + cwd: dirname(scriptPath), + encoding: 'utf8', +}); + +if (cli.stderr.trim()) { + throw new Error('CLI wrote to stderr: ' + cli.stderr.trim()); +} + +let cliRevisions; +try { + cliRevisions = JSON.parse(cli.stdout); +} catch (error) { + throw new Error('CLI did not print JSON: ' + error.message); +} + +const imported = await import(pathToFileURL(scriptPath).href); +const revisions = imported.revisions; + +if (!Array.isArray(revisions) || revisions.length !== 3) { + throw new Error('Module must export exactly three revisions'); +} + +if (JSON.stringify(cliRevisions) !== JSON.stringify(revisions)) { + throw new Error('CLI revisions do not match the import-safe module export'); +} + +const seenSlugs = new Set(); +let failed = false; + +for (const revision of revisions) { + const issues = []; + const content = revision.contentHtml || ''; + const body = bodyText(content); + const figures = [...content.matchAll(/
([\s\S]*?)<\/figure>/g)].map((match) => match[1]); + const imageSources = [...content.matchAll(/]+src="([^"]+)"/g)].map((match) => match[1]); + const proseWithoutCode = content + .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
+    .replace(/[\s\S]*?<\/code>/g, '');
+
+  if (!revision.slug || !archiveBySlug.has(revision.slug)) issues.push('slug отсутствует в базовом архиве');
+  if (seenSlugs.has(revision.slug)) issues.push('slug повторяется внутри пакета');
+  seenSlugs.add(revision.slug);
+  if (Object.hasOwn(revision, 'date') || Object.hasOwn(revision, 'author')) {
+    issues.push('ревизия не должна менять дату или автора');
+  }
+  if (body.length < 5000 || body.length > 15000) issues.push('основной текст вне 5 000–15 000 знаков');
+  if (revision.readingMinutes < 8) issues.push('меньше 8 минут чтения');
+  if (count(content, /

/g) < 5) issues.push('меньше пяти смысловых разделов'); + if (count(content, /
/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения'); + if (count(content, /
/g) < 1) issues.push('у рисунка нет подписи'); + if (count(content, //g) < 1 || count(content, //g) < 1) issues.push('нет доступной таблицы'); + if (count(content, /
/g) < 1) issues.push('нет воспроизводимого примера');
+  if (count(content, /
    /g) < 1) issues.push('нет последовательности действий'); + if (count(content, / value.startsWith('/'))) { + try { + await access(join(webRoot, 'public', source)); + } catch { + issues.push('не найден visual asset: ' + source); + } + } + + if (issues.length > 0) { + failed = true; + console.error('FAIL ' + revision.slug + ': ' + issues.join('; ')); + } else { + console.log('PASS ' + revision.slug + ': ' + body.length + ' body chars'); + } +} + +if (failed) process.exitCode = 1; diff --git a/web/scripts/upgrade-2018-09.mjs b/web/scripts/upgrade-2018-09.mjs new file mode 100644 index 0000000..39e61d4 --- /dev/null +++ b/web/scripts/upgrade-2018-09.mjs @@ -0,0 +1,425 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const escapeHtml = (value) => String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const paragraph = (content) => '

    ' + content + '

    '; +const heading = (content) => '

    ' + content + '

    '; +const codeBlock = (source) => '
    ' + escapeHtml(source.trim()) + '
    '; +const figure = (src, alt, caption) => [ + '
    ', + '' + alt + '', + '
    ' + caption + '
    ', + '
    ', +].join(''); + +function dataTable(headers, rows) { + const head = headers.map((header) => '
').join(''); + const body = rows.map((row) => ( + '' + row.map((cell) => '').join('') + '' + )).join(''); + + return '
' + header + '
' + cell + '
' + head + + '' + body + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function sourceList(items) { + return heading('Проверяемые источники') + '
'; +} + +const sources = { + environment: { + label: 'Microsoft Learn — about_Environment_Variables для Windows PowerShell', + url: 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables?view=powershell-5.1', + }, + processEnvironment: { + label: 'Microsoft Learn — Environment Variables (Win32)', + url: 'https://learn.microsoft.com/en-us/windows/win32/procthread/environment-variables', + }, + getCommand: { + label: 'Microsoft Learn — Get-Command и параметр -All', + url: 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/get-command?view=powershell-5.1', + }, + where: { + label: 'Microsoft Learn — команда where', + url: 'https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/where', + }, + chcp: { + label: 'Microsoft Learn — команда chcp', + url: 'https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/chcp', + }, + convertToJson: { + label: 'Microsoft Learn — ConvertTo-Json', + url: 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/convertto-json?view=powershell-5.1', + }, + compareObject: { + label: 'Microsoft Learn — Compare-Object', + url: 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/compare-object?view=powershell-5.1', + }, +}; + +const practiceArticle = { + slug: 'editorial-2018-09-practice-windows-dev-env', + title: 'Windows. Как сохранить минимальный снимок окружения проекта', + categories: ['Windows', 'Инструменты'], + cover: '/assets/editorial/2018/windows-dev-env-snapshot-2018.svg', + excerpt: 'Фиксируем то, что реально запускает проект на Windows: порядок PATH, найденные бинарники, версии и кодировку консоли — без копирования всего компьютера и без секретов.', + readingMinutes: 11, + contentHtml: [ + paragraph('На одном Windows-компьютере проект собирается, а на другом команда npm run build не находит node или запускает другую версию PHP. Цена такой ошибки — не только потерянный вечер: в спешке легко добавить в PATH случайную папку, переслать коллеге пароль из переменной среды или поставить непонятный архив с интерпретатором.'), + paragraph('Здесь один вопрос: какой минимальный снимок окружения нужен, чтобы другой разработчик увидел тот же запуск проекта? Не будем архивировать весь диск и делать вид, что любая разница машины важна. Зафиксируем только то, что влияет на поиск команд, их версию и текст, который видит консоль.'), + heading('Граница задачи: сохраняю запуск, а не весь компьютер'), + paragraph('Окружение процесса — это набор строк, с которым стартует конкретная консоль и её дочерние программы. В Windows есть пользовательский, системный и процессный уровни переменных. Уже открытый PowerShell не обязан получить изменения, сделанные в окне настроек: новая консоль наследует новое значение, старая продолжает работать со своим набором. Поэтому запись «у меня установлен Node» ничего не объясняет, пока не известно, какой node.exe нашёл именно этот процесс.'), + paragraph('Минимальный снимок отвечает на четыре проверяемых вопроса: какая версия Windows PowerShell запустила команду; какие папки стоят в PATH и какие расширения допускает PATHEXT; какие кандидаты вернул Get-Command -All; что напечатала сама программа для --version. В отдельной строке оставляю активную кодовую страницу консоли. Этого достаточно для первого сравнения, но в снимок не попадают токены, cookie, пароли, содержимое домашней папки и полный дамп всех переменных.'), + figure( + '/assets/editorial/2018/windows-dev-env-snapshot-2018.svg', + 'Схема минимального снимка Windows: проект запускает PowerShell, он передаёт дочерним процессам PATH и PATHEXT, затем фиксируются найденный бинарник, версия и кодовая страница в JSON без секретных переменных.', + 'Снимок описывает путь запуска команды. Он не является резервной копией компьютера и перед отправкой всё равно требует просмотра.', + ), + heading('Что кладу в файл, а что оставляю на машине'), + dataTable( + ['Поле снимка', 'Как получить', 'Зачем оно нужно', 'Чего в нём не должно быть'], + [ + ['Версия PowerShell', '$PSVersionTable.PSVersion', 'От неё зависят доступные команды и формат части вывода', 'Имя пользователя и содержимое профиля'], + ['Порядок PATH', '$env:Path -split ";"', 'Показывает, какая папка может дать первый бинарник', 'Полный список случайных переменных среды'], + ['PATHEXT', '$env:PATHEXT', 'Объясняет, какие расширения Windows считает исполняемыми', 'Изменение системного значения ради эксперимента'], + ['Кандидаты команды', 'Get-Command node -All', 'Отделяет alias или функцию от настоящего node.exe', 'Непроверенный вывод из чужого скриншота'], + ['Версия инструмента', 'node --version', 'Связывает найденный путь с фактическим запуском', 'Секреты, переданные приложению аргументами'], + ['Кодовая страница', 'cmd /c chcp', 'Помогает объяснить нечитаемый вывод старой консоли', 'Глобальное переключение кодировки без проверки'], + ], + ), + heading('Один скрипт для Windows PowerShell 5.1'), + paragraph('Ниже не установщик и не «починка» окружения. Он только собирает отчёт рядом с проектом. Список $toolChecks нужно оставить коротким: добавьте туда реальные инструменты проекта, например php и composer, а не все программы из меню Пуск. Команда Get-Command -All намеренно сохраняет все найденные варианты, потому что первый путь без остальных кандидатов часто скрывает причину расхождения.'), + codeBlock([ + '# tools/Capture-Environment.ps1', + 'param(', + " [string]$OutputPath = (Join-Path $PSScriptRoot 'environment-snapshot.json')", + ')', + '', + "$ErrorActionPreference = 'Stop'", + '$toolChecks = @(', + " [pscustomobject]@{ name = 'node'; arguments = @('--version') },", + " [pscustomobject]@{ name = 'npm'; arguments = @('--version') },", + " [pscustomobject]@{ name = 'php'; arguments = @('--version') },", + " [pscustomobject]@{ name = 'git'; arguments = @('--version') }", + ')', + '', + 'function Get-CommandCandidates {', + ' param([string]$Name)', + ' @(', + ' Get-Command -Name $Name -All -ErrorAction SilentlyContinue |', + ' ForEach-Object {', + ' [ordered]@{', + ' commandType = $_.CommandType.ToString()', + ' name = $_.Name', + ' definition = $_.Definition', + ' source = $_.Source', + ' version = if ($_.Version) { $_.Version.ToString() } else { $null }', + ' }', + ' }', + ' )', + '}', + '', + 'function Get-VersionOutput {', + ' param([string]$Name, [string[]]$Arguments)', + ' if (-not (Get-Command -Name $Name -ErrorAction SilentlyContinue)) {', + " return @('NOT FOUND')", + ' }', + ' try {', + ' $lines = @(& $Name @Arguments 2>&1 | Select-Object -First 3 | ForEach-Object { $_.ToString() })', + " return @($lines + ('exitCode=' + $LASTEXITCODE))", + ' } catch {', + " return @('FAILED: ' + $_.Exception.Message)", + ' }', + '}', + '', + '$pathEntries = @($env:Path -split ";" | ForEach-Object { $_.Trim() } | Where-Object { $_ })', + '$report = [ordered]@{', + ' formatVersion = 1', + " capturedAt = (Get-Date).ToString('o')", + ' powershell = [ordered]@{', + ' version = $PSVersionTable.PSVersion.ToString()', + ' edition = $PSVersionTable.PSEdition', + ' }', + ' console = [ordered]@{', + ' activeCodePage = ((& cmd.exe /d /c chcp) -join " ").Trim()', + ' outputEncoding = [Console]::OutputEncoding.WebName', + ' }', + ' environment = [ordered]@{', + ' pathEntries = $pathEntries', + ' pathext = $env:PATHEXT', + ' }', + ' commands = @(', + ' foreach ($check in $toolChecks) {', + ' [ordered]@{', + ' name = $check.name', + ' candidates = Get-CommandCandidates $check.name', + ' versionOutput = Get-VersionOutput $check.name $check.arguments', + ' }', + ' }', + ' )', + '}', + '', + '$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8', + "Write-Host ('Written: ' + $OutputPath)", + ].join('\n')), + paragraph('В отчёт попадает только заранее выбранный набор полей. Это полезнее, чем Get-ChildItem Env: целиком: там могут оказаться адрес прокси, ключ приложения или служебный путь. Сам PATH тоже способен раскрыть имя локального пользователя и внутренние каталоги. Перед публикацией в задаче или чате открываю JSON, заменяю такие части на нейтральные метки и сохраняю исходник только в закрытом месте.'), + heading('Снимаю отчёт в отдельном сеансе и читаю его как данные'), + paragraph('Для первой проверки полезен PowerShell без профиля: так случайная функция из $PROFILE не выдаст себя за установленный инструмент. Это не отменяет проверку обычной рабочей консоли. Наоборот, если снимки расходятся, профиль становится одной из гипотез, которую можно подтвердить отдельным запуском.'), + codeBlock([ + 'powershell.exe -NoProfile -File .\\tools\\Capture-Environment.ps1', + '', + 'Get-Content .\\tools\\environment-snapshot.json -Raw |', + ' ConvertFrom-Json |', + ' Format-List formatVersion, powershell, console, environment, commands', + '', + 'Get-Command node -All |', + ' Select-Object CommandType, Name, Version, Definition |', + ' Format-Table -AutoSize', + ].join('\n')), + paragraph('Готовый запуск не обязан вернуть все четыре инструмента. Для проекта на PHP отсутствие node может быть нормальным, а отсутствие php — нет. Важен не список «зелёных» строк, а заранее оговорённый набор. Если versionOutput содержит NOT FOUND, я не дописываю путь в системные настройки наугад: сначала смотрю кандидатов и документацию самого проекта.'), + heading('Короткий порядок работы'), + orderedList([ + 'В README назвать команды проекта и инструменты, которые им нужны: например, php, composer, node и git.', + 'Сохранить скрипт в репозитории или рядом с ним, но добавить созданный JSON в ignore, если он содержит локальные пути.', + 'Запустить снимок из обычной консоли и, при спорном случае, повторить запуск с -NoProfile.', + 'Проверить для каждого инструмента первый кандидат, остальные кандидаты и фактический вывод --version.', + 'Перед передачей отчёта убрать имя пользователя, внутренние каталоги и всё, что не требуется для разбора.', + 'После правки среды открыть новую консоль, снять новый отчёт и сравнить именно изменившиеся строки.', + ]), + heading('Где минимальный снимок не отвечает'), + paragraph('Такой файл не доказывает, что зависимости проекта совпадают. Он не заменяет lock-файл, исходный код, права доступа к каталогу, настройки прокси и архитектуру 32/64 bit. Кодовая страница консоли тоже не равна кодировке каждого файла: файл может быть сохранён в другой кодировке, а программа может читать его своим правилом. Если симптом связан с файлами, отдельно проверяю байты файла и настройки конкретного компилятора, а не объявляю chcp единственной причиной.'), + paragraph('Не стоит хранить снимок годами как истину. После обновления PHP, Node или Git он честно меняется. Его ценность в другом: у команды есть маленькая запись «что именно запускалось в этот день» и воспроизводимый способ обновить её без переустановки всего компьютера.'), + heading('Что считаю готовым'), + paragraph('Задача закрыта, когда новый разработчик может открыть JSON и ответить на три вопроса: какая консоль работала, откуда она взяла нужный бинарник и какую версию тот напечатал. Если один из ответов отсутствует, следующий шаг тоже ясен — дополнить снимок конкретным инструментом, а не обмениваться фразой «у меня работает».'), + sourceList([sources.environment, sources.getCommand, sources.chcp, sources.convertToJson]), + ].join('\n'), +}; + +const mechanismArticle = { + slug: 'editorial-2018-09-mechanism-windows-dev-env', + title: 'Windows. Почему PATH, кодировка и версия бинарника меняют запуск проекта', + categories: ['Windows', 'Инструменты'], + cover: '/assets/editorial/2018/windows-dev-env-resolution-2018.svg', + excerpt: 'Разбираем один механизм: как PowerShell выбирает команду, почему where.exe не заменяет Get-Command -All и где кодовая страница действительно влияет на диагностику.', + readingMinutes: 11, + contentHtml: [ + paragraph('В одной консоли node --version показывает ожидаемую версию, в другой — старую, а русское сообщение инструмента превращается в набор знаков. Цена ошибки выше одной неудачной сборки: можно исправить не тот node.exe, сохранить повреждённый текстовый файл или навсегда засорить системный PATH ради разового опыта.'), + paragraph('Разберём один вопрос: как Windows и PowerShell выбирают бинарник и почему рядом с этим выбором приходится проверять кодировку? Сначала увидим путь команды в текущем процессе, затем отдельно посмотрим кодовую страницу. Это две связанные проверки, но не одна «настройка окружения».'), + heading('У процесса свой набор переменных'), + paragraph('Переменная среды всегда строка, а дочерний процесс наследует её от родителя. На Windows значения могут существовать в системной, пользовательской и процессной области. Когда мы присваиваем $env:Path в текущем PowerShell, меняется только этот сеанс и программы, запущенные из него. Такой короткий опыт безопаснее постоянной правки: можно поставить папку с нужной версией первой, посмотреть результат и закрыть окно, если гипотеза не подтвердилась.'), + paragraph('Из этого следует простой, но важный вывод. Снимок PATH из нового окна и снимок из уже открытой консоли могут различаться без ошибки Windows. Если правка сделана в настройках пользователя или системы, старое приложение не переписывает свой блок переменных само. Для проверки открываю новую консоль и фиксирую время снимка, а не ожидаю, что одна строка из реестра мгновенно объяснит чужой терминал.'), + figure( + '/assets/editorial/2018/windows-dev-env-resolution-2018.svg', + 'Схема выбора команды в Windows PowerShell: процесс получает PATH и PATHEXT, Get-Command -All показывает функции, alias и приложения в порядке выбора, where.exe ищет файлы, а кодовая страница проверяется отдельной веткой.', + 'Путь к бинарнику и способ отображения его сообщения надо проверять отдельно: один отвечает за запуск, второй — за читаемость вывода.', + ), + heading('PATH и PATHEXT отвечают только за часть выбора'), + paragraph('PATH — это список каталогов, где система ищет исполняемые файлы. В Windows его элементы разделены точкой с запятой. PATHEXT задаёт расширения, которые считаются исполняемыми, поэтому имя tool может привести к tool.exe или tool.cmd. Но для PowerShell этого мало: перед приложением может оказаться alias, функция, cmdlet или внешний скрипт с тем же именем.'), + paragraph('Поэтому where.exe node полезен, но не является окончательным ответом. Он ищет файлы в текущем каталоге и папках PATH; он не покажет функцию node, определённую в профиле PowerShell. Get-Command node -All выводит все варианты в порядке, который PowerShell использует при выборе. В диагностике я запускаю обе команды: первая хорошо показывает физические файлы, вторая — фактическую команду оболочки.'), + codeBlock([ + '$names = @("node", "npm", "php", "git")', + '', + 'foreach ($name in $names) {', + ' Write-Host ""', + ' Write-Host ("== " + $name + " ==")', + ' Get-Command -Name $name -All -ErrorAction SilentlyContinue |', + ' Select-Object CommandType, Name, Version, Source, Definition |', + ' Format-Table -AutoSize', + '', + ' Write-Host "-- files found by where.exe --"', + ' where.exe $name 2>$null', + '', + ' if (Get-Command -Name $name -ErrorAction SilentlyContinue) {', + ' & $name --version', + ' Write-Host ("exitCode=" + $LASTEXITCODE)', + ' }', + '}', + ].join('\n')), + paragraph('У этой проверки есть ограничение: аргумент --version подходит не каждой утилите. Для старого проекта список аргументов лучше хранить рядом с проектом, а не использовать универсальный запуск. Если команда является функцией, её вывод ещё не доказывает путь к приложению. В таком случае сначала называю тип кандидата, затем проверяю его определение и решаю, является ли это частью проекта или случайной надстройкой профиля.'), + heading('Кодовая страница — отдельный слой'), + paragraph('chcp показывает активную кодовую страницу консоли и умеет её менять. Документация Windows отдельно оговаривает, что программы, запущенные после смены страницы, используют новое значение, а уже запущенные программы обычно сохраняют прежнее. Это объясняет частый ложный вывод «я поменял кодировку, но ошибка осталась»: проверка сделана в процессе, который стартовал раньше.'), + paragraph('Для разбора не надо немедленно переключать консоль на 65001 или менять системный язык. Сначала записываю три наблюдения: строку chcp, [Console]::OutputEncoding.WebName и байты проблемного файла, если проблема именно в файле. Кодовая страница cmd.exe, настройка вывода .NET и кодировка файла — разные вещи. Совпадение двух из них ничего не гарантирует, а изменение одного не лечит остальные.'), + codeBlock([ + '# Наблюдение, без изменения глобальных настроек.', + '& cmd.exe /d /c chcp', + '[Console]::OutputEncoding.WebName', + '[Text.Encoding]::Default.WebName', + '', + '# Короткий опыт только в текущем PowerShell.', + '$projectTools = Join-Path $PSScriptRoot "tools"', + '$env:Path = $projectTools + ";" + $env:Path', + '', + 'Get-Command node -All |', + ' Select-Object CommandType, Name, Version, Definition |', + ' Format-Table -AutoSize', + 'node --version', + ].join('\n')), + paragraph('Последние четыре строки намеренно меняют только процессную область. Если нужный node.exe оказался первым и проект прошёл документированную команду, гипотеза подтверждена. Если нет, закрываю окно — постоянного следа не осталось. Лишь после такой проверки имеет смысл обсуждать установщик, путь в пользовательском PATH или явный путь в скрипте проекта.'), + heading('Как не спутать три похожих симптома'), + dataTable( + ['Наблюдение', 'Что оно означает', 'Чем проверить', 'Следующее действие'], + [ + ['node --version даёт старую версию', 'Выбран другой кандидат или путь в PATH стоит раньше', 'Get-Command node -All и where.exe node', 'Временно поставить нужную папку первой в текущем сеансе и повторить версию'], + ['where.exe показывает два файла, а PowerShell запускает функцию', 'Физические файлы не описывают приоритет PowerShell', 'Get-Command node -All с колонкой CommandType', 'Проверить профиль и не менять PATH, пока функция не исключена'], + ['Русский вывод нечитаем только в одной консоли', 'Различается кодовая страница или настройка вывода', 'chcp и [Console]::OutputEncoding', 'Сравнить новую консоль и конкретный способ записи файла'], + ['После правки переменной результат прежний', 'Работает старый процесс с унаследованными значениями', 'Снимок из нового PowerShell без профиля', 'Открыть новый сеанс и повторить одну исходную команду'], + ['Сборка читает верный Node, но падает дальше', 'Причина не в поиске бинарника', 'Lock-файл, лог команды, права и конфигурация проекта', 'Не продолжать править PATH; расследовать следующий симптом'], + ], + ), + heading('Последовательность проверки'), + orderedList([ + 'Зафиксировать одну воспроизводимую команду проекта и её полный текст ошибки, не меняя настройки заранее.', + 'В той же консоли вывести Get-Command -All, where.exe и --version для нужного инструмента.', + 'Открыть новый PowerShell с -NoProfile и повторить ровно те же три наблюдения.', + 'Если расходится путь, добавить нужный каталог только в $env:Path текущего сеанса и проверить исходную команду.', + 'Если расходится текст, записать chcp, настройку вывода и кодировку конкретного файла, не подменяя одну проверку другой.', + 'Постоянное изменение делать лишь после того, как временный опыт дал понятный результат и его можно описать в README.', + ]), + heading('Границы такого объяснения'), + paragraph('Механизм выбора команды не объясняет всё. Бинарники могут отличаться разрядностью, зависимыми DLL, правами запуска, содержимым каталога или настройками антивируса и прокси. Эти ограничения не повод отключать защиту и начинать с переустановки Windows. Они означают, что после совпадения пути и версии нужно смотреть следующий наблюдаемый факт — лог приложения, разрешения каталога или сетевой запрос.'), + paragraph('Так же осторожно отношусь к кодировке. chcp нужен для проверки активной консоли, но не является рецептом «всегда ставить UTF-8». У старых программ и редакторов могут быть свои ожидания. Воспроизводимым считается результат, когда одна и та же программа из новой консоли выдаёт читаемый текст и открывает нужные файлы по документированному правилу.'), + heading('Итог механизма'), + paragraph('Имя команды не равно конкретному файлу. В Windows PowerShell между ними стоят процессное окружение, порядок PATH, PATHEXT и приоритет alias, функций, cmdlet и приложений. Рядом живёт отдельный слой отображения текста. Когда эти два пути проверены отдельными командами, «на моей машине другая версия» превращается в строку, которую можно сравнить и исправить без гадания.'), + sourceList([sources.environment, sources.processEnvironment, sources.getCommand, sources.where, sources.chcp]), + ].join('\n'), +}; + +const fieldArticle = { + slug: 'editorial-2018-09-field-windows-dev-env', + title: 'Windows. Разбор «работает на моей машине» без переустановки среды', + categories: ['Windows', 'Инструменты'], + cover: '/assets/editorial/2018/windows-dev-env-diff-2018.svg', + excerpt: 'Полевой маршрут для двух Windows-машин: сравнить снимки, отделить PATH от кодировки и версии бинарника, проверить одну гипотезу в новом сеансе и не стереть полезные доказательства.', + readingMinutes: 12, + contentHtml: [ + paragraph('Симптом: коллега присылает фразу «на моей машине работает», а на второй Windows-машине та же команда падает или собирает другой результат. Цена поспешного ответа — потерянный след: переустановка инструмента, очистка каталогов и правка глобального PATH меняют несколько переменных сразу и уже не дают понять, что было причиной.'), + paragraph('Разберём один вопрос: как найти различие между двумя Windows-окружениями, не превращая диагностику в серию случайных установок? Ниже учебный разбор. Пути и версии в нём условные, а не рассказ о чужом компьютере; важен порядок сбора доказательств и одна обратимая проверка за раз.'), + heading('Сначала делаю два запуска сравнимыми'), + paragraph('До снимка фиксирую одинаковые входные данные: один commit проекта, одинаковую команду, наличие lock-файла и каталог, из которого она запущена. Если на одной машине уже изменён package-lock.json или добавлены локальные правки, сравнение среды смешается со сравнением проекта. В этом случае сначала сохраняю статус VCS и называю различие, а затем возвращаюсь к окружению.'), + paragraph('На обеих машинах запускаю один и тот же Capture-Environment.ps1 из предыдущей заметки. Две копии JSON называю machine-a.json и machine-b.json. Перед отправкой в общий канал убираю личные части путей. Нам не нужен полный профиль пользователя; для расследования нужны порядок папок, кандидаты команд, версия PowerShell и признаки консоли.'), + figure( + '/assets/editorial/2018/windows-dev-env-diff-2018.svg', + 'Диагностический путь для двух Windows-машин: одинаковая команда и commit, два очищенных снимка окружения, сравнение PATH и кандидатов бинарника, временный опыт в новом PowerShell, затем повтор исходной команды.', + 'Сравнение не выбирает настройку за человека. Оно сужает вопрос до одного наблюдаемого различия и оставляет обратимый шаг проверки.', + ), + heading('Учебный пример: один репозиторий, два кандидата node'), + paragraph('Представим, что на машине A Get-Command node -All первым показывает C:\\Tools\\node-6\\node.exe, а на машине B — C:\\Program Files\\nodejs\\node.exe. В JSON это не просто две строки с разными цифрами. На машине A первая папка из PATH раньше; на машине B этой папки нет. Пока не известно, какую версию требует проект, нельзя объявить один компьютер «правильным» и переносить его путь на второй.'), + dataTable( + ['Поле сравнения', 'Машина A в учебном примере', 'Машина B в учебном примере', 'Что это доказывает'], + [ + ['Первый кандидат node', 'C:\\Tools\\node-6\\node.exe', 'C:\\Program Files\\nodejs\\node.exe', 'PowerShell может запускать разные файлы при одинаковом имени команды'], + ['Позиция каталога в PATH', 'C:\\Tools\\node-6 стоит раньше', 'Этого каталога нет', 'Различие объясняет порядок поиска, но ещё не требуемую версию проекта'], + ['node --version', 'Условно v6.x', 'Условно v8.x', 'Версия должна быть зафиксирована рядом с зависимостями проекта'], + ['Кодовая страница', 'Например, один текст chcp', 'Другая строка chcp', 'Нечитаемый вывод может потребовать отдельной проверки, но не меняет путь к бинарнику'], + ['Исходная команда', 'Падает или даёт другой лог', 'Работает в учебном случае', 'Это повод проверить одну гипотезу, а не удалить всё окружение A'], + ], + ), + heading('Превращаю JSON в сравнимые строки'), + paragraph('В снимке есть время создания и путь каждого кандидата, поэтому сырой текст файлов неудобно сравнивать целиком. Небольшая функция ниже выбирает поля, которые относятся к запуску. Она не скрывает порядок PATH: каждая папка остаётся отдельной строкой. Если на машине не найден инструмент, функция записывает это явно вместо пустого объекта.'), + codeBlock([ + '# tools/Compare-Environment.ps1', + 'function Get-SnapshotLines {', + ' param([string]$SnapshotPath)', + '', + ' $snapshot = Get-Content -LiteralPath $SnapshotPath -Raw | ConvertFrom-Json', + ' $lines = @(', + ' "powershell=" + $snapshot.powershell.version', + ' "consoleCodePage=" + $snapshot.console.activeCodePage', + ' "outputEncoding=" + $snapshot.console.outputEncoding', + ' "pathext=" + $snapshot.environment.pathext', + ' )', + '', + ' foreach ($entry in @($snapshot.environment.pathEntries)) {', + ' $lines += "path=" + $entry', + ' }', + '', + ' foreach ($tool in @($snapshot.commands)) {', + ' $first = @($tool.candidates | Select-Object -First 1)', + ' if ($first.Count -eq 0) {', + ' $lines += $tool.name + "=NOT FOUND"', + ' continue', + ' }', + ' $candidate = $first[0]', + ' $lines += ("{0}={1}|{2}|{3}" -f $tool.name, $candidate.commandType, $candidate.definition, $candidate.version)', + ' }', + '', + ' return $lines', + '}', + '', + '$machineA = Get-SnapshotLines .\\machine-a.json', + '$machineB = Get-SnapshotLines .\\machine-b.json', + 'Compare-Object -ReferenceObject $machineA -DifferenceObject $machineB', + ].join('\n')), + paragraph('Compare-Object показывает строки, существующие только в одной из сторон. Его стрелки не являются диагнозом: они лишь говорят, в каком файле встретилась строка. Сначала смотрю пару «первый кандидат инструмента + соответствующая папка PATH», затем проверяю версию командой. Если отличаются десять строк, не исправляю все десять. Выбираю различие, которое прямо связано с исходной ошибкой.'), + heading('Проверяю гипотезу в новом сеансе'), + paragraph('Допустим, README проекта требует конкретную версию Node, а учебный снимок A показывает старый бинарник раньше нужного. Сначала открываю новый PowerShell и временно добавляю каталог с нужной версией в начало $env:Path. Это действие влияет только на текущий процесс и всё, что будет запущено из него. Если исходная команда не изменилась, гипотеза про порядок PATH не подтвердилась — возвращаюсь к логу, а не переношу путь в системные переменные.'), + codeBlock([ + '# Новый PowerShell. Путь взят из документации проекта, не из случайного каталога.', + '$requiredNode = "C:\\Tools\\node-8"', + 'if (-not (Test-Path (Join-Path $requiredNode "node.exe"))) {', + ' throw "В каталоге нет node.exe: $requiredNode"', + '}', + '', + '$env:Path = $requiredNode + ";" + $env:Path', + 'Get-Command node -All |', + ' Select-Object CommandType, Name, Version, Definition |', + ' Format-Table -AutoSize', + 'node --version', + '', + '# Только после этих строк повторяется исходная команда проекта.', + 'npm run build', + ].join('\n')), + paragraph('Этот пример не предлагает брать C:\\Tools\\node-8 из статьи. Каталог и версия должны быть определены проектом или его официальной документацией. Если файл не найден, скрипт останавливается с понятной ошибкой. Он не скачивает исполняемый файл, не меняет PATH на уровне пользователя или системы и не требует отключать политику запуска сценариев.'), + heading('Как отделяю PATH, кодировку и версию'), + paragraph('Три различия часто приходят вместе, но их нельзя лечить одним действием. Старый бинарник проявляется через путь и --version. Кодовая страница проявляется через нечитаемый вывод и значения chcp или OutputEncoding. Версия зависимостей проекта проявляется через lock-файл и лог пакетного менеджера. Если меняется только текст сообщения, а путь и версия совпали, спор про PATH стоит прекратить.'), + dataTable( + ['Гипотеза', 'Минимальное доказательство', 'Обратимое действие', 'Когда остановиться'], + [ + ['В PATH раньше старая папка', 'Первый кандидат и первая отличающаяся строка пути', 'Добавить документированный каталог только в $env:Path нового PowerShell', 'Если версия или исходная ошибка не изменились'], + ['Профиль подменяет команду', 'Get-Command -All показывает функцию или alias', 'Сравнить запуск с -NoProfile', 'Если тип кандидата остаётся Application и путь тот же'], + ['Консоль портит сообщение', 'Различаются chcp и настройка вывода при одинаковом бинарнике', 'Открыть новую консоль и проверить вывод одной команды', 'Если проблема относится к сохранённому файлу, а не к консоли'], + ['Проект требует другую версию', 'README или конфигурация проекта прямо называет версию, а --version не совпадает', 'Использовать одобренную поставку этой версии в отдельном сеансе', 'Если после совпадения версии ошибка переходит к другой причине'], + ], + ), + heading('Порядок полевого разбора'), + orderedList([ + 'Сохранить commit, lock-файл и точный запуск, который расходится на двух машинах.', + 'Снять очищенные отчёты из одинакового PowerShell и не публиковать в них секреты или персональные пути.', + 'Сравнить PowerShell, кодовую страницу, порядок PATH, PATHEXT, первого кандидата и вывод версии.', + 'Выбрать одно отличие, напрямую связанное с ошибкой, и проверить его в новом сеансе только через процессное $env:Path.', + 'Повторить исходную команду и сохранить новый снимок, если результат изменился.', + 'Лишь после подтверждения обновить README или согласованный способ установки; ненужные глобальные правки не оставлять.', + ]), + heading('Что останется вне этого разбора'), + paragraph('Два одинаковых снимка не гарантируют одинаковую сборку. Причина может жить в правах на каталог, сертификате прокси, сетевом доступе, разрядности, нативной библиотеке, антивирусе, содержимом кеша или строках окончания файла. Эти варианты проверяются следующими отдельными наблюдениями. Нельзя делать вывод, что защита мешает проекту, и отключать её без подтверждённой причины и разрешённого процесса.'), + paragraph('Также не стоит превращать учебный дифф в обязательный корпоративный формат. Для маленького проекта достаточно текстового снимка и одной команды сравнения. Главное — чтобы следующий человек мог повторить путь: увидеть исходную ошибку, сравнить один набор полей, провести обратимый опыт и сказать, что именно изменилось.'), + heading('Итог: вместо «у меня работает»'), + paragraph('Фраза становится полезной, когда к ней приложены три вещи: команда, снимок процесса и сравнение кандидата бинарника. Тогда диагностика не начинается с переустановки. Мы сначала видим отличие, проверяем его в новом сеансе и только затем решаем, нужно ли менять постоянную настройку. В большинстве локальных случаев этого достаточно, чтобы вернуть разговор от мнений к наблюдаемым данным.'), + sourceList([sources.environment, sources.getCommand, sources.chcp, sources.compareObject, sources.where]), + ].join('\n'), +}; + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; + +const isDirectExecution = Boolean(process.argv[1]) + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectExecution) { + if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); + } else { + process.stderr.write('Usage: node web/scripts/upgrade-2018-09.mjs --print-revisions\n'); + } +}