@gjsify/cli 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,9 @@
11
11
  // - a toolchain salt (workspace-resolved versions of @gjsify/cli,
12
12
  // @gjsify/tsc, rolldown, typescript — a bundler/compiler bump must
13
13
  // invalidate everything),
14
- // - the package's own input hash (every file under `src/**`, plus
15
- // `package.json`, plus the package's root `tsconfig*.json` — real file
16
- // contents, streamed sha256, sorted for determinism),
14
+ // - the package's own input hash — `packageBuildInputs`, the ONE definition
15
+ // of "what are this package's build inputs" (`utils/package-inputs.ts`),
16
+ // hashed by real file contents, streamed sha256, sorted for determinism,
17
17
  // - the input hashes of EVERY workspace dependency in the package's
18
18
  // transitive dep closure (prod + dev + optional + peer `workspace:`
19
19
  // edges). Composing the closure's OWN hashes (instead of recursing into
@@ -54,6 +54,7 @@ import { dirname, join } from 'node:path';
54
54
  import { fileURLToPath } from 'node:url';
55
55
  import { buildDependencyGraph } from '@gjsify/workspace';
56
56
  import { replicateLinkSync } from './dir-link.js';
57
+ import { CONVENTIONAL_OUTPUT_DIRS, packageBuildInputs } from './package-inputs.js';
57
58
  /**
58
59
  * Bump to invalidate every existing cache entry (schema/semantic changes).
59
60
  *
@@ -66,10 +67,20 @@ import { replicateLinkSync } from './dir-link.js';
66
67
  * still on v2's whole-dir ownership and could restore over a sibling unit; a v2
67
68
  * manifest for them records `lib`, so it must not be replayed against the
68
69
  * per-child units this version stores.
70
+ *
71
+ * v4: the input set is now `packageBuildInputs` instead of `src/**` + the
72
+ * manifests (#1651). Every v3 key was computed over a strictly narrower set,
73
+ * so replaying one would report a hit for a change it could not have seen.
69
74
  */
70
- export const BUILD_CACHE_LAYOUT_VERSION = 'v3';
71
- /** Conventional per-package build output directories, checked in this order. */
72
- export const OUTPUT_DIR_CANDIDATES = ['lib', 'dist', 'dist-templates'];
75
+ export const BUILD_CACHE_LAYOUT_VERSION = 'v4';
76
+ /**
77
+ * Conventional per-package build output directories, checked in this order.
78
+ *
79
+ * Deliberately the same constant the input definition subtracts: a directory
80
+ * this cache STORES as an output must never also be hashed as an input, or
81
+ * storing it would invalidate the key it was stored under.
82
+ */
83
+ export const OUTPUT_DIR_CANDIDATES = CONVENTIONAL_OUTPUT_DIRS;
73
84
  /** Keep at most this many keys per package; evict the least recently stored/used. */
74
85
  export const MAX_KEYS_PER_PACKAGE = 2;
75
86
  /** Toolchain packages whose resolved versions salt every cache key. */
@@ -113,11 +124,50 @@ export function hashFileStream(path) {
113
124
  }
114
125
  return hash.digest('hex');
115
126
  }
116
- // Recursive, sorted file walk. Yields workspace-relative paths (forward
117
- // slashes). Directory symlinks are NOT followed (loop safety) — the link
118
- // target string participates in the hash instead. `node_modules` and the
119
- // output dirs never appear because callers only walk `src/` + explicit root
120
- // files, but guard anyway for nested oddities.
127
+ /**
128
+ * Content hash of a package's build INPUTS — the set is
129
+ * {@link packageBuildInputs}, shared with `gjsify test`'s freshness check so
130
+ * the two cannot drift about what an input is. Sorted, content-streamed:
131
+ * mtimes do not participate, so a `git checkout` with identical contents
132
+ * hashes identically across machines.
133
+ */
134
+ export function hashPackageInputs(location) {
135
+ const files = packageBuildInputs(location);
136
+ const hash = createHash('sha256');
137
+ hash.update(`gjsify-build-cache-inputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
138
+ for (const f of files) {
139
+ if (f.link !== undefined) {
140
+ // Symlink: try content (file links), fall back to the target path.
141
+ let content;
142
+ try {
143
+ content = hashFileStream(f.abs);
144
+ }
145
+ catch {
146
+ content = `link:${f.link}`;
147
+ }
148
+ hash.update(`${f.rel} ${content}\n`);
149
+ continue;
150
+ }
151
+ try {
152
+ hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
153
+ }
154
+ catch {
155
+ // Vanished mid-hash (concurrent edit) — record the absence so the
156
+ // key still changes vs. the file being present.
157
+ hash.update(`${f.rel} unreadable\n`);
158
+ }
159
+ }
160
+ return hash.digest('hex');
161
+ }
162
+ /**
163
+ * Recursive, sorted walk of an OUTPUT tree, yielding `/`-separated relative
164
+ * paths. Directory symlinks are not followed (loop safety) — the link target
165
+ * string participates in the hash instead.
166
+ *
167
+ * Deliberately NOT `packageBuildInputs`: this one is handed a directory that
168
+ * the input walk subtracts, and it must report everything inside it, dot-files
169
+ * included — an emitted `.gresource` is part of what was built.
170
+ */
121
171
  function walkFiles(dir, prefix, out) {
122
172
  let entries;
123
173
  try {
@@ -140,11 +190,8 @@ function walkFiles(dir, prefix, out) {
140
190
  continue;
141
191
  }
142
192
  if (st.isSymbolicLink()) {
143
- // File symlinks: hash the resolved content below if readable;
144
- // dir symlinks: record the target only.
145
193
  try {
146
- const target = readlinkSync(abs);
147
- out.push({ rel, abs, link: target });
194
+ out.push({ rel, abs, link: readlinkSync(abs) });
148
195
  }
149
196
  catch {
150
197
  /* dropped mid-walk */
@@ -158,58 +205,6 @@ function walkFiles(dir, prefix, out) {
158
205
  }
159
206
  }
160
207
  }
161
- /**
162
- * Content hash of a package's build INPUTS: every file under `src/**`, plus
163
- * `package.json`, plus root `tsconfig*.json`. Sorted, content-streamed —
164
- * mtimes do not participate, so a `git checkout` with identical contents
165
- * hashes identically across machines.
166
- */
167
- export function hashPackageInputs(location) {
168
- const files = [];
169
- const pkgJson = join(location, 'package.json');
170
- if (existsSync(pkgJson))
171
- files.push({ rel: 'package.json', abs: pkgJson });
172
- let rootEntries = [];
173
- try {
174
- rootEntries = readdirSync(location);
175
- }
176
- catch {
177
- /* unreadable package dir — hash degenerates to the empty set */
178
- }
179
- for (const entry of rootEntries.sort()) {
180
- if (/^tsconfig.*\.json$/.test(entry)) {
181
- files.push({ rel: entry, abs: join(location, entry) });
182
- }
183
- }
184
- const srcDir = join(location, 'src');
185
- if (existsSync(srcDir))
186
- walkFiles(srcDir, 'src', files);
187
- const hash = createHash('sha256');
188
- hash.update(`gjsify-build-cache-inputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
189
- for (const f of files) {
190
- if (f.link !== undefined) {
191
- // Symlink: try content (file links), fall back to the target path.
192
- let content;
193
- try {
194
- content = hashFileStream(f.abs);
195
- }
196
- catch {
197
- content = `link:${f.link}`;
198
- }
199
- hash.update(`${f.rel} ${content}\n`);
200
- continue;
201
- }
202
- try {
203
- hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
204
- }
205
- catch {
206
- // Vanished mid-hash (concurrent edit) — record the absence so the
207
- // key still changes vs. the file being present.
208
- hash.update(`${f.rel} unreadable\n`);
209
- }
210
- }
211
- return hash.digest('hex');
212
- }
213
208
  /**
214
209
  * Mtime-independent content hash of a set of output dirs — used to skip the
215
210
  * restore copy when the on-disk outputs already match the cached entry, and
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The status a settled CLI run still owes the shell, or `null` when it owes
3
+ * none.
4
+ *
5
+ * @param exitCode what `process.exitCode` holds now — `number | string | null |
6
+ * undefined`, the full set Node accepts for that property.
7
+ * @param isDaemon whether the command declared itself a daemon. A watch loop
8
+ * RESOLVES as soon as it is armed and keeps running, so its resolution is not
9
+ * an ending to exit on — the same rule `gjsify run`'s fast path applies, and
10
+ * the incident behind it (`gjsify run dev` killing the loop it had just
11
+ * started) is in `daemon-command.ts`.
12
+ */
13
+ export declare function pendingFailureCode(exitCode: number | string | null | undefined, isDaemon: boolean): number | null;
14
+ /**
15
+ * End a settled CLI run on the status its handler reported, on both runtimes.
16
+ *
17
+ * No-op unless a handler actually reported a failure. On Node it is a no-op
18
+ * even then — `gjsExit` finds no `imports.system` and the runtime honours
19
+ * `process.exitCode` at natural shutdown by itself, which is why this does NOT
20
+ * reach for `process.exit()`: that would cut short the stdout flush Node's own
21
+ * idiom exists to allow.
22
+ */
23
+ export declare function exitOnReportedFailure(): void;
@@ -0,0 +1,78 @@
1
+ // The CLI has TWO ways to fail, and until now only one of them reached the shell
2
+ // under GJS.
3
+ //
4
+ // A command handler either THROWS — `index.ts` catches it, prints, and calls
5
+ // `gjsExit(1)` — or it REPORTS the failure by assigning `process.exitCode` and
6
+ // returning normally. The second shape is the documented Node idiom (streams
7
+ // flush, the process ends naturally) and the CLI uses it wherever a failure has
8
+ // already been printed in full: `gjsify gresource`/`gjsify gsettings` propagate
9
+ // the code `glib-compile-*` exited with, `gjsify barrels --check` reports drift,
10
+ // `setOxcExitCode` propagates a formatter's verdict.
11
+ //
12
+ // **Under GJS that assignment is a REPORT and nothing more.** There is no atexit
13
+ // hook, so nothing reads `process.exitCode` back at natural shutdown and the
14
+ // process ends 0. Measured on the v0.49.0 bundle, same argv, same broken input:
15
+ //
16
+ // $ node lib/index.js gresource broken.gresource.xml → exit 1
17
+ // $ gjs -m dist/cli.gjs.mjs gresource broken.gresource.xml → exit 0
18
+ // ("[gjsify gresource] glib-compile-resources failed (exit 1)" printed)
19
+ //
20
+ // and the same split for `gjsify gsettings` and `gjsify barrels --check`. The
21
+ // consequence is not a cosmetic status: a package script chaining on it runs the
22
+ // next step over an artifact that was never written —
23
+ //
24
+ // "build:resources": "gjsify gresource … && echo SECOND"
25
+ // → SECOND printed, script exits 0 (JumpLink/Learn6502#180)
26
+ //
27
+ // — which is exactly the shape `&&` is supposed to protect. `gjsify run`'s
28
+ // in-process fast path was already immune (it reads `process.exitCode` back
29
+ // itself, #1568), so the hole survived precisely in the SPAWNED command, i.e.
30
+ // in every chained script and every direct CI invocation.
31
+ //
32
+ // This module is the missing half of the entry's exit funnel, and a sibling of
33
+ // `force-exit.ts`: that one is for a caller that knows the code it wants NOW,
34
+ // this one for the status a settled run left behind. The rule is pure so it can
35
+ // be stated as a test without driving a process to its death.
36
+ //
37
+ // Related, and deliberately distinct: `gjsify/deferred-process-exit` (oxlint)
38
+ // covers the OTHER GJS exit trap, a bare `process.exit()` that does not halt.
39
+ // No lint rule could have caught this one — every `process.exitCode = …` site
40
+ // is correct; the entry was not reading them.
41
+ import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
42
+ import { isDaemonCommand } from './daemon-command.js';
43
+ /**
44
+ * The status a settled CLI run still owes the shell, or `null` when it owes
45
+ * none.
46
+ *
47
+ * @param exitCode what `process.exitCode` holds now — `number | string | null |
48
+ * undefined`, the full set Node accepts for that property.
49
+ * @param isDaemon whether the command declared itself a daemon. A watch loop
50
+ * RESOLVES as soon as it is armed and keeps running, so its resolution is not
51
+ * an ending to exit on — the same rule `gjsify run`'s fast path applies, and
52
+ * the incident behind it (`gjsify run dev` killing the loop it had just
53
+ * started) is in `daemon-command.ts`.
54
+ */
55
+ export function pendingFailureCode(exitCode, isDaemon) {
56
+ if (isDaemon)
57
+ return null;
58
+ if (exitCode === null || exitCode === undefined)
59
+ return null;
60
+ const code = Number(exitCode);
61
+ // A non-finite value cannot be assigned to `process.exitCode` on Node at
62
+ // all, so there is no failure here to invent one from.
63
+ return Number.isFinite(code) && code !== 0 ? code : null;
64
+ }
65
+ /**
66
+ * End a settled CLI run on the status its handler reported, on both runtimes.
67
+ *
68
+ * No-op unless a handler actually reported a failure. On Node it is a no-op
69
+ * even then — `gjsExit` finds no `imports.system` and the runtime honours
70
+ * `process.exitCode` at natural shutdown by itself, which is why this does NOT
71
+ * reach for `process.exit()`: that would cut short the stdout flush Node's own
72
+ * idiom exists to allow.
73
+ */
74
+ export function exitOnReportedFailure() {
75
+ const code = pendingFailureCode(process.exitCode, isDaemonCommand());
76
+ if (code !== null)
77
+ gjsExit(code);
78
+ }
@@ -0,0 +1,81 @@
1
+ /** One file the build may read. `link` is set for a symlink (never followed). */
2
+ export interface PackageInputFile {
3
+ /** Package-relative path, forward slashes. */
4
+ rel: string;
5
+ abs: string;
6
+ /** Raw symlink target, when this entry is a symlink. */
7
+ link?: string;
8
+ }
9
+ /**
10
+ * Where the toolchain writes when the package does not say otherwise.
11
+ *
12
+ * Used for a package with NO `clear` script (a consumer project we know
13
+ * nothing about), and unioned onto the declared set for one that has one:
14
+ * `gjsify test` writes `dist/test.{gjs,node}.mjs` into packages whose `clear`
15
+ * line predates that command and names only `lib`, and an output left in the
16
+ * input set makes every run see a newer file than the artifact it is judging.
17
+ */
18
+ export declare const CONVENTIONAL_OUTPUT_DIRS: readonly ["lib", "dist", "dist-templates"];
19
+ /**
20
+ * Files whose change means the installed dependency tree may have moved.
21
+ *
22
+ * `node_modules` is deliberately not walked — too large to stat per run, and
23
+ * the reason the old `gjsify test` walk skipped it too — so the lockfile is
24
+ * the cheap proxy for it. That substitution is not new: `actions/build.ts`
25
+ * has made it since the plugin bundle cache existed, and this is the list it
26
+ * uses, kept in one place so a fifth package manager is added once.
27
+ */
28
+ export declare const DEP_CHANGE_LOCKFILES: readonly ["gjsify-lock.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
29
+ export interface PackageBuildInputOptions {
30
+ /** Parsed `package.json`, when the caller already has it. */
31
+ manifest?: {
32
+ scripts?: Record<string, string>;
33
+ } | null;
34
+ /** Extra package-relative paths to treat as output (e.g. a `--outdir`). */
35
+ extraOutputs?: readonly string[];
36
+ }
37
+ /**
38
+ * The paths this package PRODUCES, package-relative — DERIVED from the
39
+ * package's own `clear` script rather than re-declared, the same move
40
+ * `utils/dev-plan.ts` makes for build flags.
41
+ *
42
+ * Three cases, and the middle one is the one that matters:
43
+ * - `clear` is `gjsify clear a b c` → `a b c` plus the conventional dirs.
44
+ * 127 of 212 workspace packages, naming `tmp`, `build`, `*.tsbuildinfo`,
45
+ * `test.gjs.mjs`, and in two of them `src/*.generated.ts` — a generated
46
+ * file INSIDE `src/` that the old walk hashed as an input.
47
+ * - `clear` is anything else (`echo 'nothing to do'`, 74 packages) →
48
+ * NOTHING. The package states it has nothing to delete, so its `lib/` is
49
+ * source, not output. Exactly two of the 74 have a conventional output dir
50
+ * on disk, `resolve-npm/lib` and `manifest-conformance/lib`, and in both
51
+ * it is tracked source — so the script is a trustworthy oracle both ways.
52
+ * - no `clear` script (11 packages, and any consumer project) → the
53
+ * conventional dirs, the only guess available.
54
+ */
55
+ export declare function packageOutputPaths(manifest: {
56
+ scripts?: Record<string, string>;
57
+ } | null): string[];
58
+ /** `package.json` at `pkgRoot`, or `null` when absent/unreadable. */
59
+ export declare function readPackageManifest(pkgRoot: string): {
60
+ scripts?: Record<string, string>;
61
+ } | null;
62
+ /**
63
+ * Every file the build of the package at `pkgRoot` may read, sorted by
64
+ * package-relative path.
65
+ *
66
+ * Enumeration only — the caller decides whether to hash contents (build cache)
67
+ * or take mtimes (`gjsify test` freshness). That split is deliberate: the two
68
+ * must agree on the SET, and they legitimately differ on what they do with it.
69
+ */
70
+ export declare function packageBuildInputs(pkgRoot: string, options?: PackageBuildInputOptions): PackageInputFile[];
71
+ /** Newest mtime (ms) among `files`, or 0 for an empty set. */
72
+ export declare function newestInputMtimeMs(files: readonly PackageInputFile[]): number;
73
+ /**
74
+ * Newest lockfile mtime (ms) at or above `startDir`, or 0 when there is none.
75
+ *
76
+ * Walks up because in a monorepo the lockfile that governs a package's
77
+ * `node_modules` sits at the workspace root, several levels away — and a
78
+ * dependency bump that rewrites it is precisely the case #1651 was found in
79
+ * (postbote, gjsify 0.33.0 → 0.49.0, not one source file touched).
80
+ */
81
+ export declare function newestDepSignalMtimeMs(startDir: string): number;
@@ -0,0 +1,233 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // ONE answer to "what are this package's build inputs" — read by the build
3
+ // cache (`build-cache.ts`) and by `gjsify test`'s freshness check. Add a
4
+ // fourth READER, never a fourth answer.
5
+ //
6
+ // It was answered three times, each an ALLOW-list over a guessed layout, and
7
+ // each is wrong for a package that already exists here: `src/**` misses
8
+ // `@gjsify/adwaita-fonts` (no `src/` at all) and the tracked source in
9
+ // `resolve-npm/lib` + `manifest-conformance/lib` (#821); `dirname(<test
10
+ // entry>)` missed `src/**` entirely, so `gjsify test` reran the PREVIOUS
11
+ // bundle and reported on it (#1651).
12
+ //
13
+ // So: a DENY-list. An input is any file in the package that is not a
14
+ // dependency, not tool/VCS state, and not something the package produces —
15
+ // which covers a layout nobody anticipated by construction. **In doubt, a
16
+ // file is an input**: a false input costs one rebuild, a false non-input
17
+ // costs a green test run about code that is not in the artifact.
18
+ //
19
+ // The incident, the measurements behind every number below, and the copy
20
+ // still open (CI's `actions/cache` glob): docs/build-artifacts.md.
21
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { hasWildcard, segmentToRegExp } from './clear-targets.js';
24
+ import { gjsifyCommandArgv } from './simple-command.js';
25
+ /**
26
+ * Where the toolchain writes when the package does not say otherwise.
27
+ *
28
+ * Used for a package with NO `clear` script (a consumer project we know
29
+ * nothing about), and unioned onto the declared set for one that has one:
30
+ * `gjsify test` writes `dist/test.{gjs,node}.mjs` into packages whose `clear`
31
+ * line predates that command and names only `lib`, and an output left in the
32
+ * input set makes every run see a newer file than the artifact it is judging.
33
+ */
34
+ export const CONVENTIONAL_OUTPUT_DIRS = ['lib', 'dist', 'dist-templates'];
35
+ /**
36
+ * Files whose change means the installed dependency tree may have moved.
37
+ *
38
+ * `node_modules` is deliberately not walked — too large to stat per run, and
39
+ * the reason the old `gjsify test` walk skipped it too — so the lockfile is
40
+ * the cheap proxy for it. That substitution is not new: `actions/build.ts`
41
+ * has made it since the plugin bundle cache existed, and this is the list it
42
+ * uses, kept in one place so a fifth package manager is added once.
43
+ */
44
+ export const DEP_CHANGE_LOCKFILES = ['gjsify-lock.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
45
+ /**
46
+ * The paths this package PRODUCES, package-relative — DERIVED from the
47
+ * package's own `clear` script rather than re-declared, the same move
48
+ * `utils/dev-plan.ts` makes for build flags.
49
+ *
50
+ * Three cases, and the middle one is the one that matters:
51
+ * - `clear` is `gjsify clear a b c` → `a b c` plus the conventional dirs.
52
+ * 127 of 212 workspace packages, naming `tmp`, `build`, `*.tsbuildinfo`,
53
+ * `test.gjs.mjs`, and in two of them `src/*.generated.ts` — a generated
54
+ * file INSIDE `src/` that the old walk hashed as an input.
55
+ * - `clear` is anything else (`echo 'nothing to do'`, 74 packages) →
56
+ * NOTHING. The package states it has nothing to delete, so its `lib/` is
57
+ * source, not output. Exactly two of the 74 have a conventional output dir
58
+ * on disk, `resolve-npm/lib` and `manifest-conformance/lib`, and in both
59
+ * it is tracked source — so the script is a trustworthy oracle both ways.
60
+ * - no `clear` script (11 packages, and any consumer project) → the
61
+ * conventional dirs, the only guess available.
62
+ */
63
+ export function packageOutputPaths(manifest) {
64
+ const clear = manifest?.scripts?.clear;
65
+ if (clear === undefined)
66
+ return [...CONVENTIONAL_OUTPUT_DIRS];
67
+ // `allowGlobs`: five packages here clear `*.tsbuildinfo`, and the pattern
68
+ // is what we want — this reads the targets, it does not run them.
69
+ const argv = gjsifyCommandArgv(clear, { allowGlobs: true });
70
+ if (argv === null || argv[0] !== 'clear')
71
+ return [];
72
+ const targets = argv.slice(1).filter((token) => !token.startsWith('-'));
73
+ return [...targets, ...CONVENTIONAL_OUTPUT_DIRS];
74
+ }
75
+ /** `package.json` at `pkgRoot`, or `null` when absent/unreadable. */
76
+ export function readPackageManifest(pkgRoot) {
77
+ try {
78
+ return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8'));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /**
85
+ * Match a package-relative path against output targets — the target itself and
86
+ * everything under it.
87
+ *
88
+ * Wildcards are honoured in the LAST segment only, which is `gjsify clear`'s
89
+ * own rule (`utils/clear-targets.ts`), so the set matched here is the set that
90
+ * command would delete.
91
+ */
92
+ function makeOutputMatcher(targets) {
93
+ const compiled = targets
94
+ .map((target) => target
95
+ .split(/[\\/]+/)
96
+ .filter((segment) => segment !== '' && segment !== '.')
97
+ .map((segment) => (hasWildcard(segment) ? segmentToRegExp(segment) : segment)))
98
+ .filter((segments) => segments.length > 0);
99
+ return (rel) => {
100
+ const parts = rel.split('/');
101
+ for (const target of compiled) {
102
+ if (target.length > parts.length)
103
+ continue;
104
+ let matched = true;
105
+ for (let i = 0; i < target.length; i++) {
106
+ const segment = target[i];
107
+ const got = parts[i];
108
+ if (typeof segment === 'string' ? segment !== got : !segment.test(got)) {
109
+ matched = false;
110
+ break;
111
+ }
112
+ }
113
+ if (matched)
114
+ return true;
115
+ }
116
+ return false;
117
+ };
118
+ }
119
+ function walk(dir, prefix, isOutput, out) {
120
+ let entries;
121
+ try {
122
+ entries = readdirSync(dir);
123
+ }
124
+ catch {
125
+ return;
126
+ }
127
+ entries.sort();
128
+ for (const entry of entries) {
129
+ // `node_modules` is the dependency tree, not this package's source, and
130
+ // a dot-entry is VCS/editor/cache state — `.git` alone would make the
131
+ // walk unbounded. Measured across every package here: no file under any
132
+ // `src/` is dot-prefixed, so nothing the old hash covered is lost.
133
+ if (entry === 'node_modules' || entry.startsWith('.'))
134
+ continue;
135
+ const rel = prefix === '' ? entry : `${prefix}/${entry}`;
136
+ if (isOutput(rel))
137
+ continue;
138
+ const abs = join(dir, entry);
139
+ let st;
140
+ try {
141
+ st = lstatSync(abs);
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ if (st.isSymbolicLink()) {
147
+ // Not followed: a directory symlink can loop, and a workspace
148
+ // `node_modules` link would drag the whole tree in. The target
149
+ // string participates instead; file links are read by content where
150
+ // the consumer asks for content.
151
+ out.push({ rel, abs, link: readLinkTarget(abs) });
152
+ }
153
+ else if (st.isDirectory()) {
154
+ walk(abs, rel, isOutput, out);
155
+ }
156
+ else if (st.isFile()) {
157
+ out.push({ rel, abs });
158
+ }
159
+ }
160
+ }
161
+ function readLinkTarget(abs) {
162
+ try {
163
+ return readlinkSync(abs);
164
+ }
165
+ catch {
166
+ // Dropped between lstat and readlink. An empty target still differs
167
+ // from the file being absent, which is all the hash needs.
168
+ return '';
169
+ }
170
+ }
171
+ /**
172
+ * Every file the build of the package at `pkgRoot` may read, sorted by
173
+ * package-relative path.
174
+ *
175
+ * Enumeration only — the caller decides whether to hash contents (build cache)
176
+ * or take mtimes (`gjsify test` freshness). That split is deliberate: the two
177
+ * must agree on the SET, and they legitimately differ on what they do with it.
178
+ */
179
+ export function packageBuildInputs(pkgRoot, options = {}) {
180
+ const manifest = options.manifest !== undefined ? options.manifest : readPackageManifest(pkgRoot);
181
+ const outputs = [...packageOutputPaths(manifest), ...(options.extraOutputs ?? [])];
182
+ const files = [];
183
+ walk(pkgRoot, '', makeOutputMatcher(outputs), files);
184
+ return files;
185
+ }
186
+ /** Newest mtime (ms) among `files`, or 0 for an empty set. */
187
+ export function newestInputMtimeMs(files) {
188
+ let newest = 0;
189
+ for (const file of files) {
190
+ try {
191
+ const { mtimeMs } = statSync(file.abs);
192
+ if (mtimeMs > newest)
193
+ newest = mtimeMs;
194
+ }
195
+ catch {
196
+ // Vanished mid-walk. Treat as a change: an input we cannot read is
197
+ // not evidence that the artifact is current.
198
+ return Number.POSITIVE_INFINITY;
199
+ }
200
+ }
201
+ return newest;
202
+ }
203
+ /**
204
+ * Newest lockfile mtime (ms) at or above `startDir`, or 0 when there is none.
205
+ *
206
+ * Walks up because in a monorepo the lockfile that governs a package's
207
+ * `node_modules` sits at the workspace root, several levels away — and a
208
+ * dependency bump that rewrites it is precisely the case #1651 was found in
209
+ * (postbote, gjsify 0.33.0 → 0.49.0, not one source file touched).
210
+ */
211
+ export function newestDepSignalMtimeMs(startDir) {
212
+ let newest = 0;
213
+ let dir = startDir;
214
+ for (;;) {
215
+ for (const name of DEP_CHANGE_LOCKFILES) {
216
+ const candidate = join(dir, name);
217
+ if (!existsSync(candidate))
218
+ continue;
219
+ try {
220
+ const { mtimeMs } = statSync(candidate);
221
+ if (mtimeMs > newest)
222
+ newest = mtimeMs;
223
+ }
224
+ catch {
225
+ /* raced away — the other lockfiles still answer */
226
+ }
227
+ }
228
+ const parent = dirname(dir);
229
+ if (parent === dir)
230
+ return newest;
231
+ dir = parent;
232
+ }
233
+ }
@@ -38,19 +38,20 @@ export declare function changelogEntriesFor(version: string, source: string): st
38
38
  * wrong: exactly one leading space, then `--`, one space, the maintainer, and TWO
39
39
  * spaces before the date.
40
40
  *
41
- * TWO lintian tags this file still raises, both measured on lintian 2.117 against
42
- * gjsify's own `.deb` and both left alone deliberately:
43
- *
44
- * - `initial-upload-closes-no-bugs`, because the Debian revision is `-1` and the
45
- * entry closes no bug. It is a statement about the Debian ARCHIVE's ITP
46
- * workflow — the first upload of a new source package should close its ITP bug
47
- * — and a package built by `gjsify ship` is never uploaded there. There is no
48
- * bug number that would be true to write, so the tag is inapplicable rather
49
- * than unmet.
50
- * - `changelog-not-compressed-with-max-compression`. Policy asks for `gzip -9`
51
- * and neither `CompressionStream` nor `@gjsify/zlib` takes a level, so
52
- * `gzipDeterministic` cannot ask for one. Ledgered in `status/open-todos.md`:
53
- * the fix is a capability in the core, and stamping the header's XFL byte to
54
- * claim max compression we did not use is not one.
41
+ * ONE lintian tag this file still raises, measured on lintian 2.117 against
42
+ * gjsify's own `.deb` and left alone deliberately: `initial-upload-closes-no-bugs`,
43
+ * because the Debian revision is `-1` and the entry closes no bug. It is a
44
+ * statement about the Debian ARCHIVE's ITP workflow — the first upload of a new
45
+ * source package should close its ITP bug — and a package built by `gjsify ship`
46
+ * is never uploaded there. There is no bug number that would be true to write, so
47
+ * the tag is inapplicable rather than unmet.
48
+ *
49
+ * `changelog-not-compressed-with-max-compression` was a second one, and closing it
50
+ * was a capability gap in the CORE rather than anything wrong in this file:
51
+ * neither `CompressionStream` nor `@gjsify/zlib` would take a compression level,
52
+ * so `gzipDeterministic` had none to pass on. `@gjsify/zlib` honours
53
+ * `options.level` now and `plan.ts` compresses this file at
54
+ * `POLICY_MAX_COMPRESSION`. `verify-deb.sh` gates the tag by name, so it cannot
55
+ * come back quietly.
55
56
  */
56
57
  export declare function renderDebianChangelog(settings: PackSettings, source: string | undefined, mtime: number): string;
@@ -134,20 +134,21 @@ const WIDTH = 80;
134
134
  * wrong: exactly one leading space, then `--`, one space, the maintainer, and TWO
135
135
  * spaces before the date.
136
136
  *
137
- * TWO lintian tags this file still raises, both measured on lintian 2.117 against
138
- * gjsify's own `.deb` and both left alone deliberately:
137
+ * ONE lintian tag this file still raises, measured on lintian 2.117 against
138
+ * gjsify's own `.deb` and left alone deliberately: `initial-upload-closes-no-bugs`,
139
+ * because the Debian revision is `-1` and the entry closes no bug. It is a
140
+ * statement about the Debian ARCHIVE's ITP workflow — the first upload of a new
141
+ * source package should close its ITP bug — and a package built by `gjsify ship`
142
+ * is never uploaded there. There is no bug number that would be true to write, so
143
+ * the tag is inapplicable rather than unmet.
139
144
  *
140
- * - `initial-upload-closes-no-bugs`, because the Debian revision is `-1` and the
141
- * entry closes no bug. It is a statement about the Debian ARCHIVE's ITP
142
- * workflow — the first upload of a new source package should close its ITP bug
143
- * — and a package built by `gjsify ship` is never uploaded there. There is no
144
- * bug number that would be true to write, so the tag is inapplicable rather
145
- * than unmet.
146
- * - `changelog-not-compressed-with-max-compression`. Policy asks for `gzip -9`
147
- * and neither `CompressionStream` nor `@gjsify/zlib` takes a level, so
148
- * `gzipDeterministic` cannot ask for one. Ledgered in `status/open-todos.md`:
149
- * the fix is a capability in the core, and stamping the header's XFL byte to
150
- * claim max compression we did not use is not one.
145
+ * `changelog-not-compressed-with-max-compression` was a second one, and closing it
146
+ * was a capability gap in the CORE rather than anything wrong in this file:
147
+ * neither `CompressionStream` nor `@gjsify/zlib` would take a compression level,
148
+ * so `gzipDeterministic` had none to pass on. `@gjsify/zlib` honours
149
+ * `options.level` now and `plan.ts` compresses this file at
150
+ * `POLICY_MAX_COMPRESSION`. `verify-deb.sh` gates the tag by name, so it cannot
151
+ * come back quietly.
151
152
  */
152
153
  export function renderDebianChangelog(settings, source, mtime) {
153
154
  const entries = source === undefined ? [] : changelogEntriesFor(settings.version, source);