diff --git a/scripts/__tests__/bump-objectstack.test.ts b/scripts/__tests__/bump-objectstack.test.ts index 6705015d87..8afa657499 100644 --- a/scripts/__tests__/bump-objectstack.test.ts +++ b/scripts/__tests__/bump-objectstack.test.ts @@ -138,8 +138,8 @@ describe('scripts/bump-objectstack.sh (objectui#12957)', () => { // The pin was built, as the pin and not as a stand-in for the checkout read above. expect(buildCall(objectui)).toEqual({ argv: ['build', '--write-manifests'], standIn: false }); expect(git(objectui, 'log', '-1', '--format=%s')).toBe(`chore: bump objectstack to ${mainTip.slice(0, 12)}`); - expect(git(objectui, 'show', '--name-only', '--format=', 'HEAD').split('\n').sort()).toEqual(['.objectstack-pin/spec/package.json', '.objectstack-sha']); - expect(git(objectui, 'show', 'HEAD:.objectstack-pin/spec/package.json')).toContain(mainTip); + expect(git(objectui, 'show', '--name-only', '--relative', '--format=', 'HEAD').split('\n').sort()).toEqual(['.objectstack-pin/spec/package.json', '.objectstack-sha']); + expect(git(objectui, 'show', 'HEAD:./.objectstack-pin/spec/package.json')).toContain(mainTip); }); it('REFUSES when the build of the new pin fails, restores the old pin, and commits nothing', () => { diff --git a/scripts/__tests__/git-paths-nested-layout.test.ts b/scripts/__tests__/git-paths-nested-layout.test.ts new file mode 100644 index 0000000000..2a2df76515 --- /dev/null +++ b/scripts/__tests__/git-paths-nested-layout.test.ts @@ -0,0 +1,171 @@ +/** + * Paths handed to git, in BOTH layouts (objectstack#25728). Standalone the git + * repository root is the objectui tree; under the objectstack monorepo it is one + * level up, and the tree is `ui/`. `scripts/workspace-root.mjs` ("Paths handed + * to git") writes down the idiom -- git runs in the tree, a path into a revision + * is `revPath(ref, path)` (`:./`), a path listing carries + * `--relative` -- and this file holds the readers that were the first reds on + * the merged tree to the same answer in a scratch repository of each shape. The + * nested shape is the one this checkout is not, so both branches run here + * whichever layout the checkout is. + */ +import { afterAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { revPath } from '../workspace-root.mjs'; +import { LOCALES, analyze, packPath, readPacks, treeExistsAt } from '../check-i18n-en-drift.mjs'; +import { changedFiles } from '../check-changeset-presence.mjs'; +import { readBlobs, treeIndex } from '../check-changeset-claims.mjs'; +import { collisions } from '../check-changeset-overwrite.mjs'; +import { changedFiles as affectedChangedFiles } from '../select-affected-tests.mjs'; + +const tempRoots: string[] = []; +afterAll(() => { + for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); +}); + +const pack = (value: string) => `export default {\n common: {\n save: '${value}',\n },\n};\n`; +const allPacks = (value: string) => Object.fromEntries(LOCALES.map((lang: string) => [packPath(lang), pack(value)])); + +/** + * A repository whose objectui tree sits at `prefix` (`''` standalone, `'ui/'` + * nested; then with one file of the monorepo's own beside it), and two commits. + * Returns the TREE (what the scripts are handed as `root`) and both shas. + */ +function layout(prefix: string): { tree: string; base: string; head: string } { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'git-paths-nested-')); + tempRoots.push(repo); + const git = (...args: string[]) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }).trim(); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'gate@example.test'); + git('config', 'user.name', 'gate'); + const write = (files: Record) => { + for (const [rel, contents] of Object.entries(files)) { + const full = path.join(repo, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + }; + const under = (files: Record) => Object.fromEntries(Object.entries(files).map(([rel, body]) => [prefix + rel, body])); + + write({ + ...under({ + ...allPacks('Save'), + 'packages/a/package.json': '{ "name": "@object-ui/a" }\n', + '.changeset/keep.md': "---\n'@object-ui/a': patch\n---\n\nKeep.\n", + }), + ...(prefix ? { 'monorepo-own.txt': 'one\n' } : {}), + }); + git('add', '-A'); + git('commit', '-q', '-m', 'base'); + const base = git('rev-parse', 'HEAD'); + + write({ + ...under({ [packPath('en')]: pack('Save now'), '.changeset/keep.md': "---\n'@object-ui/a': minor\n---\n\nKeep, louder.\n" }), + ...(prefix ? { 'monorepo-own.txt': 'two\n' } : {}), + }); + git('add', '-A'); + git('commit', '-q', '-m', 'head'); + const head = git('rev-parse', 'HEAD'); + return { tree: path.join(repo, prefix), base, head }; +} + +describe('check:i18n-drift over the change that IMPORTS the tree (objectstack#25728)', () => { + it('reads an empty before side when the tree does not exist at the base, and still throws when only a pack is missing', () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'git-paths-import-')); + tempRoots.push(repo); + const git = (...args: string[]) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }).trim(); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'gate@example.test'); + git('config', 'user.name', 'gate'); + const write = (files: Record) => { + for (const [rel, contents] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true }); + fs.writeFileSync(path.join(repo, rel), contents); + } + }; + write({ 'monorepo-own.txt': 'one\n' }); + git('add', '-A'); + git('commit', '-q', '-m', 'the monorepo before the import'); + const preImport = git('rev-parse', 'HEAD'); + write(Object.fromEntries(Object.entries(allPacks('Save')).map(([rel, body]) => [`ui/${rel}`, body]))); + git('add', '-A'); + git('commit', '-q', '-m', 'import ui/'); + const imported = git('rev-parse', 'HEAD'); + const tree = path.join(repo, 'ui'); + + expect(treeExistsAt(tree, preImport)).toBe(false); + const result = analyze(tree, { base: preImport, head: imported }); + expect(result.treeAtBase).toBe(false); + expect(result.findings).toEqual([]); + expect(result.counters.addedEnKeys).toBe(1); + + // The tree exists at the base but a pack does not: a broken input, never an empty side. + fs.rmSync(path.join(tree, packPath('ja'))); + git('add', '-A'); + git('commit', '-q', '-m', 'lose a pack'); + const lost = git('rev-parse', 'HEAD'); + expect(() => analyze(tree, { base: lost, head: imported })).toThrow(/cannot read/); + }); +}); + +describe('revPath', () => { + it('names a file at a revision relative to the cwd git runs in', () => { + expect(revPath('HEAD', 'packages/i18n/src/locales/en.ts')).toBe('HEAD:./packages/i18n/src/locales/en.ts'); + expect(revPath('abc123', './scripts/x.mjs')).toBe('abc123:./scripts/x.mjs'); + }); +}); + +for (const [name, prefix] of [ + ['standalone', ''], + ['nested under ui/', 'ui/'], +] as const) { + describe(`git paths, ${name} (objectstack#25728)`, () => { + const { tree, base, head } = layout(prefix); + + it('reads a file at a revision through revPath, where the root-relative spelling only works standalone', () => { + const show = (spec: string) => execFileSync('git', ['show', spec], { cwd: tree, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + expect(show(revPath(base, packPath('en')))).toBe(pack('Save')); + // The control: the spelling the readers used before. It must fail nested, + // or this file would be measuring nothing. + const rootRelative = () => show(`${base}:${packPath('en')}`); + if (prefix) expect(rootRelative).toThrow(); + else expect(rootRelative()).toBe(pack('Save')); + }); + + it('check:i18n-drift reads the base packs and reaches its verdict (the run-4 red)', () => { + expect(treeExistsAt(tree, base)).toBe(true); + expect(readPacks(tree, base).get('en')?.get('common.save')).toBe('Save'); + const result = analyze(tree, { base, head }); + expect(result.treeAtBase).toBe(true); + // en moved and none of the nine followers did: one finding naming all nine. + expect(result.counters.changedEnKeys).toBe(1); + expect(result.findings.map((f: { key: string; unfollowed: string[] }) => [f.key, f.unfollowed.length])).toEqual([['common.save', LOCALES.length - 1]]); + }); + + it('changed-file listings are relative to the tree and leave the monorepo’s own files out', () => { + const want = ['.changeset/keep.md', packPath('en')].sort(); + // The working tree is the gate's default "after" side; it equals `head` here. + expect(changedFiles(tree, { base }).sort()).toEqual(want); + expect(affectedChangedFiles(tree, base, head).sort()).toEqual(want); + }); + + it('the tree index at a revision and in the working tree agree, and its blobs read', () => { + const atHead = treeIndex(tree, head); + const working = treeIndex(tree, null); + expect([...atHead.paths].sort()).toEqual([...working.paths].sort()); + expect(atHead.paths.has('packages/a/package.json')).toBe(true); + expect(readBlobs(tree, head, ['packages/a/package.json']).get('packages/a/package.json')).toContain('@object-ui/a'); + }); + + it('check:changeset-overwrite reads the edited changeset at its base and in the working tree', () => { + const { modified } = collisions(tree, { base }); + expect(modified.map((m: { file: string; before: unknown[]; after: unknown[]; lost: string[] }) => [m.file, m.before.length, m.after.length, m.lost])).toEqual([ + ['.changeset/keep.md', 1, 1, []], + ]); + }); + }); +} diff --git a/scripts/check-changeset-claims.mjs b/scripts/check-changeset-claims.mjs index 27feece70d..62593fe4de 100644 --- a/scripts/check-changeset-claims.mjs +++ b/scripts/check-changeset-claims.mjs @@ -283,6 +283,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; import { NOT_A_CHANGESET, changedFiles, describeDeclaration, resolveBaseRef } from './check-changeset-presence.mjs'; +import { revPath } from './workspace-root.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -353,7 +354,9 @@ function git(root, args, { allowFailure = false } = {}) { } /** - * Every tracked path at `ref`, indexed by basename. + * Every tracked path at `ref`, indexed by basename, relative to `root` on both + * sides: `ls-files` and `ls-tree` (without `--full-tree`) answer from git's cwd + * (scripts/workspace-root.mjs, "Paths handed to git"). * * `ref === null` means the working tree, so an author running this locally is * answered about the checkout in front of them. UNTRACKED files join the @@ -369,7 +372,7 @@ export function treeIndex(root, ref) { const listing = ref === null ? git(root, ['ls-files']) + git(root, ['ls-files', '--others', '--exclude-standard']) - : git(root, ['ls-tree', '-r', '--name-only', '--full-tree', ref]); + : git(root, ['ls-tree', '-r', '--name-only', ref]); const paths = new Set(); const byBasename = new Map(); for (const path of listing.split('\n')) { @@ -427,7 +430,7 @@ export function readBlobs(root, ref, paths) { const batch = execFileSync('git', ['cat-file', '--batch'], { cwd: root, - input: paths.map((path) => `${ref}:${path}`).join('\n') + '\n', + input: paths.map((path) => revPath(ref, path)).join('\n') + '\n', maxBuffer: 512 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -1122,7 +1125,7 @@ export function dateClaim(root, { base, head = null }, file, claim, pkg) { // The base first — see the note above. A claim that was already standing when // this change started is dated by the front matter it was standing against, // never by the commit that just moved it. - const atBase = git(root, ['show', `${base}:${file}`], { allowFailure: true }); + const atBase = git(root, ['show', revPath(base, file)], { allowFailure: true }); if (carries(atBase)) return declaredPackages(atBase).includes(pkg) ? 'born' : 'went'; const range = head ? `${base}..${head}` : base; @@ -1130,7 +1133,7 @@ export function dateClaim(root, { base, head = null }, file, claim, pkg) { const commits = (log ?? '').split('\n').filter((line) => line !== ''); const needle = claim.replace(/\s+/g, ' ').toLowerCase(); for (const commit of commits) { - const source = git(root, ['show', `${commit}:${file}`], { allowFailure: true }); + const source = git(root, ['show', revPath(commit, file)], { allowFailure: true }); if (source === null) continue; const body = source .replace(/^---\r?\n[\s\S]*?\r?\n---/, '') diff --git a/scripts/check-changeset-overwrite.mjs b/scripts/check-changeset-overwrite.mjs index cbb92c4089..40924f9a3c 100644 --- a/scripts/check-changeset-overwrite.mjs +++ b/scripts/check-changeset-overwrite.mjs @@ -105,6 +105,7 @@ import { resolveBaseRef, untrackedChangesets, } from './check-changeset-presence.mjs'; +import { revPath } from './workspace-root.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -166,7 +167,7 @@ function contentAt(root, ref, file) { } } try { - return execFileSync('git', ['show', `${ref}:${file}`], { + return execFileSync('git', ['show', revPath(ref, file)], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, diff --git a/scripts/check-changeset-presence.mjs b/scripts/check-changeset-presence.mjs index 05cc46b835..2971dfffa7 100644 --- a/scripts/check-changeset-presence.mjs +++ b/scripts/check-changeset-presence.mjs @@ -424,6 +424,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { revPath } from './workspace-root.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -567,10 +568,14 @@ export function resolveBaseRef(root, { explicit = null, env = process.env } = {} * diff that cannot be computed is not an empty diff. * * @param {string[]} pathspecs extra pathspecs; `[]` means the whole tree - * @returns {string[]} repo-relative POSIX paths + * `--relative`: paths come back relative to `root`, not to the git repository + * root, so they read the same standalone and under the monorepo's `ui/` + * (scripts/workspace-root.mjs, "Paths handed to git"). + * + * @returns {string[]} POSIX paths relative to `root` */ export function changedFiles(root, { base, head = null, filter = null, pathspecs = [] }) { - const args = ['diff', '--name-only', '-z']; + const args = ['diff', '--name-only', '--relative', '-z']; if (filter) args.push(`--diff-filter=${filter}`); args.push(base); if (head) args.push(head); @@ -888,7 +893,7 @@ function manifestAt(root, ref, file) { const onDisk = join(root, file); source = existsSync(onDisk) ? readFileSync(onDisk, 'utf8') : null; } else { - source = gitQuiet(root, ['show', `${ref}:${file}`]); + source = gitQuiet(root, ['show', revPath(ref, file)]); } if (source === null) return null; try { @@ -993,7 +998,7 @@ export function addedDeclarations(root, { base, head = null }) { seen.add(file); // Added at `head`, so it is readable there — from disk when `head` is the // working tree, out of the object store otherwise. - const source = head === null ? readFileSync(join(root, file), 'utf8') : gitQuiet(root, ['show', `${head}:${file}`]); + const source = head === null ? readFileSync(join(root, file), 'utf8') : gitQuiet(root, ['show', revPath(head, file)]); results.push({ file, declaration: source === null ? { kind: 'none' } : describeDeclaration(source) }); } return results.sort((a, b) => a.file.localeCompare(b.file)); diff --git a/scripts/check-i18n-en-drift.mjs b/scripts/check-i18n-en-drift.mjs index c6eba4430b..51056e75b1 100644 --- a/scripts/check-i18n-en-drift.mjs +++ b/scripts/check-i18n-en-drift.mjs @@ -138,6 +138,20 @@ * cannot drift from the packs it claims to read. Anything the parser does not * understand THROWS rather than being skipped: a silently dropped subtree would * make this gate green over exactly the keys it stopped reading. + * + * ## A base commit with no objectui tree at all (objectstack#25728) + * + * Paths into a revision are `revPath(ref, path)` (`scripts/workspace-root.mjs`, + * "Paths handed to git"), so under the objectstack monorepo the packs are read + * from `ui/` at the base. One change has no such tree at its base: the one that + * IMPORTS objectui as `ui/` (and every branch cut from it before it lands). Its + * merge base is objectstack `main` without `ui/`, so no pack can be read there. + * That is not a missing pack, it is a missing BEFORE side: every key is new in + * the change, and drift between two sides needs two. `treeExistsAt` asks git + * whether the tree itself (`:./`) exists; only when it does not is the + * before side empty, and the CLI says so. Standalone the tree is the repository + * root, which exists at every commit, so this never fires there; a tree that + * exists at the base with a pack missing still THROWS, as it always did. */ import ts from 'typescript'; @@ -151,6 +165,7 @@ import { readDesignerPairs, } from './check-i18n-designer-table-parity.mjs'; import { isEntrypoint } from './invoked-as.mjs'; +import { revPath } from './workspace-root.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -329,14 +344,14 @@ export function readPacks(root, ref) { const packs = new Map(); for (const lang of LOCALES) { const rel = packPath(lang); - const label = ref === null ? rel : `${ref}:${rel}`; + const label = ref === null ? rel : revPath(ref, rel); let text; if (ref === null) { const full = join(root, rel); if (!existsSync(full)) throw new Error(`${rel} does not exist in the working tree`); text = readFileSync(full, 'utf8'); } else { - text = gitQuiet(root, ['show', `${ref}:${rel}`]); + text = gitQuiet(root, ['show', revPath(ref, rel)]); if (text === null) throw new Error(`cannot read ${label} — is ${ref} in this clone?`); } packs.set(lang, parsePack(text, label)); @@ -567,10 +582,23 @@ export function applyLedger(findings, ledger, enAfter) { * @param {{ base: string, head?: string | null }} options */ export function analyze(root, { base, head = null }) { - const before = readPacks(root, base); + const treeAtBase = treeExistsAt(root, base); + const before = treeAtBase ? readPacks(root, base) : new Map(LOCALES.map((lang) => [lang, new Map()])); const after = readPacks(root, head); const { findings, counters } = findDrift(before, after); - return { before, after, findings, counters }; + return { before, after, findings, counters, treeAtBase }; +} + +/** + * Whether the tree git runs in exists at `ref` (`:./`). False only for a + * change that imports the tree -- see "A base commit with no objectui tree". + * + * @param {string} root + * @param {string} ref + * @returns {boolean} + */ +export function treeExistsAt(root, ref) { + return gitQuiet(root, ['cat-file', '-e', revPath(ref, '')]) !== null; } // -- the designer table, a SECOND population (objectui#8834) ------------------ @@ -620,14 +648,14 @@ export const DESIGNER_FOLLOWERS = ['zh']; * @returns {{ values: Map>, fileMissing: boolean }} */ export function readDesignerTables(root, ref, { require = true } = {}) { - const label = ref === null ? DESIGNER_TABLE : `${ref}:${DESIGNER_TABLE}`; + const label = ref === null ? DESIGNER_TABLE : revPath(ref, DESIGNER_TABLE); let text; if (ref === null) { const full = join(root, DESIGNER_TABLE); if (!existsSync(full)) throw new Error(`${DESIGNER_TABLE} does not exist in the working tree`); text = readFileSync(full, 'utf8'); } else { - text = gitQuiet(root, ['show', `${ref}:${DESIGNER_TABLE}`]); + text = gitQuiet(root, ['show', revPath(ref, DESIGNER_TABLE)]); if (text === null) { if (require) throw new Error(`cannot read ${label} — is ${ref} in this clone?`); return { values: new Map(), fileMissing: true }; @@ -720,7 +748,7 @@ if (invokedDirectly) { } const head = argOf('--head'); - const { after, findings, counters } = analyze(root, { base: base.ref, head }); + const { after, findings, counters, treeAtBase } = analyze(root, { base: base.ref, head }); const enAfter = after.get('en'); // Guard against a refactor quietly emptying the comparison: with no keys, every @@ -770,7 +798,11 @@ if (invokedDirectly) { `(${counters.addedEnKeys} key(s) added, ${counters.removedEnKeys} removed — those are ` + `all-locales-key-parity's), ${counters.followedPacks} pack value(s) followed, ` + `${counters.absentInPack} pack/key pair(s) skipped because the pack does not define the key, ` + - `${waived.length} finding(s) waived by the ledger.`, + `${waived.length} finding(s) waived by the ledger` + + (treeAtBase + ? '.' + : ' — this tree does not exist at the base commit (the change imports it), so every key is new ' + + 'here and there was no before side to compare.'), ); console.log( diff --git a/scripts/check-lockfile-integrity.mjs b/scripts/check-lockfile-integrity.mjs index 8a28b9d0d9..263030281a 100644 --- a/scripts/check-lockfile-integrity.mjs +++ b/scripts/check-lockfile-integrity.mjs @@ -116,7 +116,7 @@ import fs from 'node:fs'; import process from 'node:process'; import { isEntrypoint } from './invoked-as.mjs'; -import { LOCKFILE, lockfilePath, workspaceRoot } from './workspace-root.mjs'; +import { LOCKFILE, lockfilePath, revPath, workspaceRoot } from './workspace-root.mjs'; export const EXIT_CLEAN = 0; export const EXIT_FINDINGS = 1; @@ -372,13 +372,13 @@ function flagValue(args, flag) { function readSource(spec) { if (spec.includes(':') && !fs.existsSync(spec)) { const [rev, file] = [spec.slice(0, spec.indexOf(':')), spec.slice(spec.indexOf(':') + 1)]; - const out = spawnSync('git', ['show', `${rev}:${file}`], { + const out = spawnSync('git', ['show', revPath(rev, file)], { cwd: workspaceRoot(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }); if (out.status !== 0) { - throw new Error(`git show ${rev}:${file} failed: ${(out.stderr || '').trim()}`); + throw new Error(`git show ${revPath(rev, file)} failed: ${(out.stderr || '').trim()}`); } return out.stdout; } @@ -391,8 +391,9 @@ function readSource(spec) { */ export function resolveSources(argv) { // The one lockfile sits at the workspace root -- this repository's own, or - // the monorepo's one level up (objectstack#25723). `:` below is - // relative to the repository root, which in both layouts is that same directory. + // the monorepo's one level up (objectstack#25723). `:` is read as + // `revPath(rev, path)` with git's cwd at that root, so `` is relative to + // the workspace root in both layouts (objectstack#25728). const head = flagValue(argv, '--head') ?? lockfilePath(); const explicitBase = flagValue(argv, '--base'); if (explicitBase) return { base: explicitBase, head }; diff --git a/scripts/check-ui-root-lookups.mjs b/scripts/check-ui-root-lookups.mjs index a04f2f0af4..9aa5b238e1 100644 --- a/scripts/check-ui-root-lookups.mjs +++ b/scripts/check-ui-root-lookups.mjs @@ -50,12 +50,47 @@ * - SILENT a `.git` entry in a directory skip-list, a fixture that WRITES a * `pnpm-workspace.yaml`, and any mention inside a comment. * + * ## Paths handed to git (objectstack#25728) + * + * The same move one level down: git reads some paths against the REPOSITORY + * root, whatever cwd it runs in, and under the monorepo that root is one level + * above the objectui tree. `scripts/workspace-root.mjs` ("Paths handed to + * git") writes down the one idiom -- git's own cwd-relative forms -- and this + * gate refuses the root-relative spellings it replaced. An argument list is a + * git argv when it is an array literal whose first element (after `-C ` / + * `-c ` pairs) is a git subcommand string, the string arguments of a call + * whose callee is named `git...`, or a string command that starts `git `: + * + * - FIRES `git-rev-path`: a `show` / `cat-file` argv, or the call that + * spawns one (so a `cat-file --batch` `input:` is in reach), holding + * a `:` string or template whose path part does not start + * with `./` -- `${ref}:${file}`, `HEAD:packages/x`. Spell it + * `revPath(ref, file)`; + * - FIRES `git-root-listing`: a `diff` / `diff-tree` / `diff-index` / + * `log` / `show` / `whatchanged` argv with a path-listing flag + * (`--name-only`, `--name-status`, `--numstat`, `--stat`, `--raw`, + * `--summary`) and no `--relative`; an `ls-files` / `ls-tree` / + * `grep` argv with `--full-name` or `--full-tree`; + * - SILENT `revPath(...)`, `:./`, a `^{commit}` probe, a + * pathspec after `--`, `ls-files` / `ls-tree` / `grep` without + * those flags, and a flag string that only starts with `-`. + * + * ⚠️ The bound, said per AGENTS.md #9: the detector reads argv it can SEE. + * An argv assembled across statements from names alone, a `:` + * string built outside the spawning call and handed in by name, a `diff` whose + * PATCH headers (`+++ b/`) are parsed for paths, `status --porcelain` + * (root-relative by design, and read here only as "is the tree clean"), and + * git run with a cwd other than the tree (so `./` resolves elsewhere) are out + * of its reach; the idiom in `scripts/workspace-root.mjs` is the rule, this is + * its tripwire. + * * ## The allowlist is a ledger, not a bypass * * Every entry names a file and the reason its probe is not a repo-root lookup * (a CLI asking whether the USER's cwd is a workspace; the pin script, which * the merge deletes; `scripts/workspace-root.mjs`, whose two probes are - * anchored on `uiRoot()` and decide the WORKSPACE root once for every reader). + * anchored on `uiRoot()` and decide the WORKSPACE root once for every reader; + * the governed-surface guard, whose subject is the repository's own root). * An entry whose file no longer carries a hit is STALE and reds the run, so the * list can only shrink without being tended. */ @@ -90,6 +125,25 @@ const UPWARD_STEPS = new Set(['dirname']); const PATH_COMBINERS = new Set(['join', 'resolve']); const GIT_ROOT_FLAGS = new Set(['--show-toplevel', '--git-common-dir']); +/** Every git subcommand this gate reads an argv for; a list whose head is not one of these is not a git argv. */ +const GIT_SUBCOMMANDS = new Set(['show', 'cat-file', 'diff', 'diff-tree', 'diff-index', 'log', 'whatchanged', 'ls-files', 'ls-tree', 'grep']); +/** Subcommands whose `:` argument git resolves from the REPOSITORY root unless the path starts with `./`. */ +const REV_PATH_SUBCOMMANDS = new Set(['show', 'cat-file']); +/** Subcommands whose path listings are repository-root-relative unless `--relative`. */ +const DIFF_SUBCOMMANDS = new Set(['diff', 'diff-tree', 'diff-index', 'log', 'show', 'whatchanged']); +const PATH_LISTING_FLAG = /^--(?:name-only|name-status|numstat|stat|raw|summary)(?:=|$)/; +/** Subcommands that answer from the cwd unless told to answer from the root. */ +const CWD_LISTING_SUBCOMMANDS = new Set(['ls-files', 'ls-tree', 'grep']); +const FULL_PATH_FLAGS = new Set(['--full-name', '--full-tree']); +/** Placeholder for a template substitution when a template is read as a shape. */ +const HOLE = '\u0001'; +/** + * `:` with something before the colon and a path after it that does + * not start with `./`. A leading `-` is a flag (`--format=%H:%s`); a leading + * `:` is pathspec magic (`:(glob)x`), not a revision. + */ +const ROOT_REV_PATH = /^[^\s:\-][^\s:]*:(?!\.\/)[^\s]/; + /** * Files that carry a probe this gate recognises and that is NOT a repo-root * lookup. Path → reason. A path listed here that carries no hit is stale. @@ -107,6 +161,10 @@ export const ALLOWLIST = new Map([ 'scripts/check-new-cross-file-line-citations.mjs', "the gate's subject is the REPOSITORY AT THE CWD (its self-test drives it against a scratch repository), so `gateRoot()` reads the toplevel and narrows to `uiRoot()` only when the objectui tree lies inside it -- a probe of THAT repository, not a lookup of this root", ], + [ + 'scripts/check-governed-queue-guard.mjs', + "its subject is the REPOSITORY's governed surfaces (`AGENTS.md`, `.github/workflows/**`, ... at the git root), so its `diff-tree --name-only` reads root-relative paths on purpose; under the monorepo the governed register is objectstack's own (objectstack#25712)", + ], ['packages/cli/src/utils/app-generator.ts', "asks whether the USER's cwd is a pnpm workspace to choose the scaffold shape -- product behaviour, not a repo-root lookup"], ['packages/cli/src/utils/workspace-vite.ts', "asks whether the USER's cwd is a pnpm workspace before reading its globs -- product behaviour, not a repo-root lookup"], ['packages/cli/src/__tests__/app-generator.test.ts', 'mirrors the generator\'s own cwd predicate to compute the expected scaffold shape'], @@ -114,7 +172,7 @@ export const ALLOWLIST = new Map([ // Cheap pre-filter: a file that spells none of these cannot carry a hit. It is // a SPEED filter only; every file that mentions one is parsed in full. -const PREFILTER = /\.git['"`]|pnpm-workspace\.yaml|--show-toplevel|--git-common-dir|find-up|findUp/; +const PREFILTER = /\.git['"`]|pnpm-workspace\.yaml|--show-toplevel|--git-common-dir|find-up|findUp|['"`](?:show|cat-file|diff|diff-tree|diff-index|log|whatchanged|ls-files|ls-tree|grep)['"`]|['"`]git /; function calleeName(node) { const e = node.expression; @@ -183,6 +241,50 @@ function loopWalksUpward(loop) { return found; } +/** + * A string-ish node read as a SHAPE: its text, with every template substitution + * replaced by one placeholder character. `null` for anything else. + */ +function shapeOf(node) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isTemplateExpression(node)) return node.head.text + node.templateSpans.map((s) => HOLE + s.literal.text).join(''); + return null; +} + +/** + * The git argv in a list of argument SHAPES (strings; `null` for a non-string + * node): an optional run of `-C ` / `-c ` pairs, then a subcommand. + * `leading` non-string nodes may precede it (a wrapper's `root` argument). + * + * @param {(string | null)[]} shapes + * @returns {{ sub: string, rest: (string | null)[] } | null} + */ +function gitArgv(shapes, leading = 0) { + let i = 0; + while (i < leading && i < shapes.length && shapes[i] === null) i += 1; + while (shapes[i] === '-C' || shapes[i] === '-c') i += 2; + const sub = shapes[i]; + return typeof sub === 'string' && GIT_SUBCOMMANDS.has(sub) ? { sub, rest: shapes.slice(i + 1) } : null; +} + +/** Whether an argv lists paths from the REPOSITORY root (see the header's `git-root-listing`). */ +function listsFromGitRoot({ sub, rest }) { + const flags = rest.filter((s) => typeof s === 'string'); + if (DIFF_SUBCOMMANDS.has(sub)) { + return flags.some((f) => PATH_LISTING_FLAG.test(f)) && !flags.some((f) => f === '--relative' || f.startsWith('--relative=')); + } + if (CWD_LISTING_SUBCOMMANDS.has(sub)) return flags.some((f) => FULL_PATH_FLAGS.has(f)); + return false; +} + +const isRootRevPath = (shape) => typeof shape === 'string' && ROOT_REV_PATH.test(shape); + +/** Whether a callee is a git wrapper by name: `git`, `gitQuiet`, `runGit`, `this.git`. */ +function isGitCallee(call) { + const name = calleeName(call); + return typeof name === 'string' && /git/i.test(name); +} + const isLoop = (node) => ts.isForStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node); /** @@ -190,14 +292,18 @@ const isLoop = (node) => ts.isForStatement(node) || ts.isWhileStatement(node) || * * @param {string} file Path used for reporting and for the script kind. * @param {string} text - * @returns {{ line: number, shape: 'marker-probe' | 'marker-walk' | 'git-toplevel' | 'find-up', text: string }[]} + * @returns {{ line: number, shape: 'marker-probe' | 'marker-walk' | 'git-toplevel' | 'find-up' | 'git-rev-path' | 'git-root-listing', text: string }[]} */ export function lookupsIn(file, text) { const kind = /\.tsx$/.test(file) ? ts.ScriptKind.TSX : /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS; const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind); const binds = bindingsOf(src); const hits = []; + const seen = new Set(); const record = (node, shape) => { + const key = `${node.getStart(src)}:${shape}`; + if (seen.has(key)) return; + seen.add(key); hits.push({ line: src.getLineAndCharacterOfPosition(node.getStart(src)).line + 1, shape, @@ -208,7 +314,52 @@ export function lookupsIn(file, text) { // inside one that steps upward is the walk's existence test, not a data read. const loops = []; const inUpwardWalk = () => loops.some(loopWalksUpward); + // Every `:` under `scope` that names its path from the git root. + const recordRevPaths = (scope) => { + const visit = (n) => { + if (isRootRevPath(shapeOf(n))) record(n, 'git-rev-path'); + ts.forEachChild(n, visit); + }; + visit(scope); + }; + // One git argv, read from `nodes`; `scope` is what a `:` may hide in. + const judgeArgv = (anchor, nodes, scope, leading = 0) => { + const argv = gitArgv(nodes.map(shapeOf), leading); + if (!argv) return false; + if (listsFromGitRoot(argv)) record(anchor, 'git-root-listing'); + if (REV_PATH_SUBCOMMANDS.has(argv.sub)) { + for (const n of nodes) if (isRootRevPath(shapeOf(n))) record(n, 'git-rev-path'); + if (scope) recordRevPaths(scope); + } + return true; + }; + // A string command (`execSync('git show ...')`), split on whitespace. + const judgeCommand = (node) => { + const shape = shapeOf(node); + if (shape === null || !/^git\s/.test(shape)) return; + const words = shape.trim().split(/\s+/).slice(1).map((w) => w.replace(/^(['"])(.*)\1$/, '$2')); + const argv = gitArgv(words); + if (!argv) return; + if (listsFromGitRoot(argv)) record(node, 'git-root-listing'); + if (REV_PATH_SUBCOMMANDS.has(argv.sub) && argv.rest.some(isRootRevPath)) record(node, 'git-rev-path'); + }; + const walk = (node) => { + if (ts.isArrayLiteralExpression(node)) { + judgeArgv(node, node.elements, null); + } else if (ts.isCallExpression(node)) { + // The spawning call: an argv array among its arguments puts the whole + // call (a `cat-file --batch` `input:` included) in reach of the rev-path rule. + for (const arg of node.arguments) { + if (ts.isArrayLiteralExpression(arg)) { + const argv = gitArgv(arg.elements.map(shapeOf)); + if (argv && REV_PATH_SUBCOMMANDS.has(argv.sub)) recordRevPaths(node); + } + } + // A variadic wrapper: `git(root, 'show', 'HEAD:x')`. + if (isGitCallee(node)) judgeArgv(node, node.arguments, null, 2); + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) judgeCommand(node); if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === 'find-up') { record(node, 'find-up'); } else if (ts.isCallExpression(node)) { @@ -301,6 +452,29 @@ export function selfTest() { run('a comment that mentions every spelling', `// walks up to .git or pnpm-workspace.yaml, like git rev-parse --show-toplevel or find-up\nexport const x = 1;`, 'silent'); run('the helper itself', `import { uiRoot } from '../../../../scripts/ui-root.mjs';\nconst README = join(uiRoot(), 'packages/core/README.md');\nif (existsSync(README)) {}`, 'silent'); run('an existsSync of something else', `${head}if (existsSync(join(root, 'packages/types/package.json'))) {}`, 'silent'); + // ── Paths handed to git (objectstack#25728) ────────────────────────────── + // FIRES + run('`git show :` through a wrapper (the check:i18n-drift red)', `${head}const text = gitQuiet(root, ['show', \`\${ref}:\${rel}\`]);`, 'fires', 'scripts/x.mjs'); + run('`git show` of a literal root-relative path', `${head}execFileSync('git', ['show', 'origin/main:packages/i18n/src/locales/en.ts'], { cwd: root });`, 'fires', 'scripts/x.mjs'); + run('a `cat-file --batch` input naming paths from the root', `${head}execFileSync('git', ['cat-file', '--batch'], { cwd: root, input: paths.map((p) => \`\${ref}:\${p}\`).join('\\n') });`, 'fires', 'scripts/x.mjs'); + run('`-C ` before `show`', `${head}spawnSync('git', ['-C', root, 'show', \`\${rev}:\${file}\`]);`, 'fires', 'scripts/x.mjs'); + run('a variadic wrapper `git(root, "show", "HEAD:x")`', `const out = git(repo, 'show', 'HEAD:packages/x/package.json');`, 'fires'); + run('a string command `git show :`', `${head}execSync(\`git show \${base}:\${file}\`);`, 'fires', 'scripts/x.mjs'); + run('`diff --name-only` without --relative (an argv built then pushed to)', `const args = ['diff', '--name-only', '-z'];\nargs.push(base);`, 'fires', 'scripts/x.mjs'); + run('`diff-tree --name-status` without --relative', `${head}git(root, ['diff-tree', '-r', '--name-status', sha]);`, 'fires', 'scripts/x.mjs'); + run('`log --numstat` without --relative', `${head}git(root, ['log', '--numstat', '--format=%H', range]);`, 'fires', 'scripts/x.mjs'); + run('`ls-tree --full-tree`', `${head}git(root, ['ls-tree', '-r', '--name-only', '--full-tree', ref]);`, 'fires', 'scripts/x.mjs'); + run('`ls-files --full-name`', `${head}execFileSync('git', ['ls-files', '--full-name', '-z'], { cwd: root });`, 'fires', 'scripts/x.mjs'); + // SILENT + run('`revPath(ref, file)`', `import { revPath } from './workspace-root.mjs';\n${head}gitQuiet(root, ['show', revPath(ref, rel)]);`, 'silent', 'scripts/x.mjs'); + run('`:./` spelled out', `${head}execFileSync('git', ['show', \`\${rev}:./\${path}\`], { cwd: root });`, 'silent', 'scripts/x.mjs'); + run('a `^{commit}` probe and a ref range', `${head}git(root, ['cat-file', '-e', \`\${sha}^{commit}\`]);\ngit(root, ['log', '--format=%H:%s', \`\${base}..\${head}\`]);`, 'silent', 'scripts/x.mjs'); + run('`diff --name-only --relative`', `${head}git(root, ['diff', '--name-only', '--relative', base, head]);`, 'silent', 'scripts/x.mjs'); + run('a patch `diff` with a pathspec after `--`', `${head}git(root, ['diff', '-U0', '--no-color', base, '--', file]);`, 'silent', 'scripts/x.mjs'); + run('`ls-files` / `ls-tree` / `grep` answering from the cwd', `${head}git(root, ['ls-files', '-z']);\ngit(root, ['ls-tree', '-r', '--name-only', ref]);\ngit(root, ['grep', '-n', '-e', key, '--', 'packages']);`, 'silent', 'scripts/x.mjs'); + run('pathspec magic, not a revision', `${head}git(root, ['ls-files', '--others', '--', ':(glob).changeset/*.md']);\ngit(root, ['diff', '--relative', '--name-only', base, '--', ':(glob).changeset/*.md']);`, 'silent', 'scripts/x.mjs'); + run('a `:` diagnostic outside any git argv', `const where = \`\${file}:\${line}\`;\nconsole.log(['show', where].join(' '));`, 'silent', 'scripts/x.mjs'); + run('a list that only looks like an argv', `const ROWS = [['status', 'Aggregate closure ceiling'], ['log', 'info', 'warn']];`, 'silent', 'scripts/x.mjs'); return cases; } diff --git a/scripts/select-affected-tests.mjs b/scripts/select-affected-tests.mjs index a72a005188..7270814406 100644 --- a/scripts/select-affected-tests.mjs +++ b/scripts/select-affected-tests.mjs @@ -427,9 +427,12 @@ export function trackedFiles(root = REPO_ROOT) { .filter(Boolean); } -/** The files changed between the merge base of `base` and `head`, and `head`. */ +/** + * The files changed between the merge base of `base` and `head`, and `head`, + * relative to `root` (`--relative`; scripts/workspace-root.mjs, "Paths handed to git"). + */ export function changedFiles(root, base, head = 'HEAD') { - return execFileSync('git', ['diff', '--name-only', '--no-renames', `${base}...${head}`], { + return execFileSync('git', ['diff', '--name-only', '--relative', '--no-renames', `${base}...${head}`], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, diff --git a/scripts/workspace-root.d.mts b/scripts/workspace-root.d.mts index 42a1503262..a65fa1e44b 100644 --- a/scripts/workspace-root.d.mts +++ b/scripts/workspace-root.d.mts @@ -19,6 +19,8 @@ export declare function workspaceRoot(root?: string): string; export declare function isNestedWorkspace(root?: string): boolean; /** The tree's directory-name prefix in the parent's globs (`ui/`), or `''`. */ export declare function nestedPrefix(root?: string): string; +/** `:./`: a file at a revision, relative to the cwd git runs in. */ +export declare function revPath(ref: string, file: string): string; export declare function workspaceManifestPath(root?: string): string; export declare function lockfilePath(root?: string): string; export declare function rootManifestPath(root?: string): string; diff --git a/scripts/workspace-root.mjs b/scripts/workspace-root.mjs index 3e5e068e46..818116ac73 100644 --- a/scripts/workspace-root.mjs +++ b/scripts/workspace-root.mjs @@ -55,6 +55,35 @@ * computed before: the file is at the objectui root, so rule (a) picks it and * rule (b) returns the globs verbatim. * + * ## Paths handed to git: git's own cwd-relative forms (objectstack#25728) + * + * The second half of the same class. Standalone, the git repository root IS + * the objectui tree; under the monorepo it is the OBJECTSTACK root, one level + * up, and git reads some paths against that root whatever `cwd` the script + * spawns it with. `git show :packages/x` then asks for a file that sits + * at `ui/packages/x`, and `git diff --name-only` answers `ui/packages/x` to a + * reader that matches `packages/...`. The one idiom, for every script: + * + * - git runs with `cwd` at the tree it judges -- `uiRoot()`, or the + * repository under test when a gate is driven against a scratch repo; + * - a path INTO a revision is `revPath(ref, path)`, i.e. `:./`: + * git resolves `./` against the cwd, so the same spelling names the same + * file in both layouts (and in a scratch repo, where `./` is the root); + * - a path listing OUT of `diff` / `diff-tree` / `log` / `show` + * (`--name-only`, `--name-status`, `--numstat`, `--stat`, `--raw`) carries + * `--relative`, so git prints paths from the cwd and leaves out the + * monorepo's own files; + * - `ls-files`, `ls-tree`, `grep` and a pathspec after `--` are already + * cwd-relative, so they never carry `--full-name` / `--full-tree`. + * + * Standalone every one of these is a no-op (the cwd is the repository root), + * so a reader's answer there is the one it computed before. Calls that hand git + * no path (`rev-parse`, `merge-base`, `cat-file -e `) need nothing. + * `nestedPrefix()` below is the WORKSPACE answer and is not used for git: git + * already knows where the cwd sits, and a scratch repo has no workspace. + * `pnpm check:ui-root-lookups` refuses the root-relative spellings so the class + * does not grow back; its header names the shapes and the bound. + * * ## The one parser * * Three scripts carried their own `packages:` parser; `parseWorkspaceGlobs` is @@ -125,6 +154,18 @@ export function nestedPrefix(root = uiRoot()) { return isNestedWorkspace(root) ? `${path.basename(path.resolve(root))}/` : ''; } +/** + * `:./` -- a file at a revision, named relative to the cwd git runs + * in rather than to the repository root (see "Paths handed to git" above). + * + * @param {string} ref a revision (`HEAD`, a sha, `origin/main`) + * @param {string} file POSIX path relative to the tree git runs in + * @returns {string} + */ +export function revPath(ref, file) { + return `${ref}:./${file.replace(/^(?:\.\/)+/, '')}`; +} + /** @param {string} [root] @returns {string} absolute path of the governing `pnpm-workspace.yaml`. */ export function workspaceManifestPath(root = uiRoot()) { return path.join(workspaceRoot(root), WORKSPACE_MANIFEST);