okf-kit 0.9.0 → 0.11.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.
- package/CHANGELOG.md +256 -0
- package/README.md +102 -8
- package/dist/bundle.d.ts +18 -1
- package/dist/bundle.js +8 -1
- package/dist/bundle.js.map +1 -1
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +25 -0
- package/dist/cli.js.map +1 -1
- package/dist/git.d.ts +2 -2
- package/dist/git.js +16 -2
- package/dist/git.js.map +1 -1
- package/dist/rules/index.d.ts +2 -2
- package/dist/rules/index.js +8 -2
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/sources-fresh.d.ts +42 -0
- package/dist/rules/sources-fresh.js +797 -20
- package/dist/rules/sources-fresh.js.map +1 -1
- package/dist/types.d.ts +26 -0
- package/dist/util.d.ts +52 -0
- package/dist/util.js +72 -0
- package/dist/util.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { parseFrontmatter } from "../bundle.js";
|
|
3
4
|
import { runGit as defaultRunGit } from "../git.js";
|
|
4
|
-
import { getTimestampEpoch, getValidSources } from "../util.js";
|
|
5
|
+
import { getRawTimestampString, getTimestampEpoch, getTimestampIdentity, getValidSources, hasUtcDesignator, } from "../util.js";
|
|
5
6
|
const RULE_ID = "sources-fresh";
|
|
7
|
+
const FUTURE_RULE_ID = "sources-fresh-future";
|
|
8
|
+
/**
|
|
9
|
+
* Default clock-skew allowance (seconds) for `sources-fresh-future`: a doc
|
|
10
|
+
* timestamp up to this far after the doc's own last commit is still treated
|
|
11
|
+
* as fresh, absorbing the ordinary gap between "author wrote the timestamp"
|
|
12
|
+
* and "the commit that carries it landed". Override via
|
|
13
|
+
* `ctx.freshnessFutureSkewSeconds` (CLI: `--future-skew-minutes`).
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_FUTURE_SKEW_SECONDS = 600;
|
|
6
16
|
export const sourcesFreshRule = {
|
|
7
17
|
id: RULE_ID,
|
|
8
|
-
description: "Frontmatter `sources` paths must not have a last-commit time newer than the doc's `timestamp
|
|
18
|
+
description: "Frontmatter `sources` paths must not have a last-commit time newer than the doc's `timestamp`, unless the doc's own last commit lands at/after the source's and that same commit actually re-stamped the doc: the doc's parsed frontmatter `timestamp` VALUE at that commit differs from its value in the commit's first parent (rename-aware; creating the doc counts as re-stamping it). Creation is trusted only in a genuinely unshallow repository: in a shallow clone, a commit with no parents can simply be where history was cut off, not a real root commit, so it gets the same `not assessable` notice instead of being assumed created. When git cannot answer the question at all, the doc gets a `not assessable` notice instead of either a STALE warning or a silent pass.",
|
|
9
19
|
run(ctx) {
|
|
10
20
|
const findings = [];
|
|
11
|
-
const docsWithSources = ctx
|
|
12
|
-
.map((doc) => ({ doc, sources: getValidSources(doc.frontmatter.parsed) }))
|
|
13
|
-
.filter((entry) => entry.sources !== undefined);
|
|
21
|
+
const docsWithSources = getDocsWithSources(ctx);
|
|
14
22
|
if (docsWithSources.length === 0)
|
|
15
23
|
return findings;
|
|
16
24
|
if (!ctx.repoRoot) {
|
|
@@ -18,6 +26,9 @@ export const sourcesFreshRule = {
|
|
|
18
26
|
// either (sources-shape), so staleness truly was not assessed, not
|
|
19
27
|
// "everything looked fine". One notice for the whole bundle, not one
|
|
20
28
|
// per doc: this is a bundle-level condition, not a per-doc finding.
|
|
29
|
+
// sources-fresh-future shares this same population and posture; it
|
|
30
|
+
// relies on this single notice too instead of emitting its own (see
|
|
31
|
+
// that rule below).
|
|
21
32
|
findings.push({
|
|
22
33
|
ruleId: RULE_ID,
|
|
23
34
|
severity: "notice",
|
|
@@ -28,17 +39,39 @@ export const sourcesFreshRule = {
|
|
|
28
39
|
}
|
|
29
40
|
const repoRoot = ctx.repoRoot;
|
|
30
41
|
const git = ctx.runGit ?? defaultRunGit;
|
|
42
|
+
// `--dirty-as-now` asked for, but git could not tell us what is dirty
|
|
43
|
+
// (a `status` past RunGit's output cap, a `rev-parse` failure, git
|
|
44
|
+
// missing): every path below then falls through to committed history,
|
|
45
|
+
// which is the RIGHT fallback but the WRONG silence -- "the flag found
|
|
46
|
+
// nothing dirty" and "the flag never ran" produce the identical empty
|
|
47
|
+
// finding list. Say so once, bundle-level, in the same style as the
|
|
48
|
+
// "not inside a git work tree" notice above (and, like that one,
|
|
49
|
+
// emitted only here: `sources-fresh-future` shares the same degraded
|
|
50
|
+
// state and relies on this single notice instead of duplicating it).
|
|
51
|
+
if (ctx.dirtyAsNow && getDirtyStateShared(ctx, git, repoRoot) === null) {
|
|
52
|
+
findings.push({
|
|
53
|
+
ruleId: RULE_ID,
|
|
54
|
+
severity: "notice",
|
|
55
|
+
file: "",
|
|
56
|
+
message: "`--dirty-as-now` not applied: git status could not be read, judging by committed history only",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
31
59
|
// One git call per unique source path across all docs, even though a
|
|
32
60
|
// STALE/untracked finding is reported per (doc, path) below.
|
|
33
61
|
const commitEpochCache = new Map();
|
|
34
|
-
|
|
62
|
+
// `--dirty-as-now`: every dirty path's "commit epoch" is the SAME
|
|
63
|
+
// virtual-commit instant, shared (by `ctx` identity) with
|
|
64
|
+
// `sources-fresh-future`'s doc-epoch lookup below -- see
|
|
65
|
+
// `commitEpochWithDirtyAsNow`'s doc comment for why this lives in one
|
|
66
|
+
// place instead of being reimplemented per rule.
|
|
67
|
+
const commitEpochFor = (source) => commitEpochWithDirtyAsNow(ctx, git, repoRoot, source, () => {
|
|
35
68
|
const cached = commitEpochCache.get(source);
|
|
36
69
|
if (cached !== undefined)
|
|
37
70
|
return cached;
|
|
38
71
|
const epoch = getLastCommitEpoch(git, repoRoot, source);
|
|
39
72
|
commitEpochCache.set(source, epoch);
|
|
40
73
|
return epoch;
|
|
41
|
-
};
|
|
74
|
+
});
|
|
42
75
|
for (const { doc, sources } of docsWithSources) {
|
|
43
76
|
const timestampEpoch = getTimestampEpoch(doc.frontmatter.parsed);
|
|
44
77
|
if (timestampEpoch === undefined) {
|
|
@@ -51,15 +84,68 @@ export const sourcesFreshRule = {
|
|
|
51
84
|
continue;
|
|
52
85
|
}
|
|
53
86
|
// A doc whose own last commit is at or after the source's last commit
|
|
54
|
-
// (typically: both landed in one squash-merge)
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
87
|
+
// (typically: both landed in one squash-merge), AND that same commit
|
|
88
|
+
// actually re-stamped the doc, is not stale, even if its frontmatter
|
|
89
|
+
// timestamp predates the merge -- see restampedByOwnLastCommit below
|
|
90
|
+
// for what "re-stamped" means and why the plain commit-ordering check
|
|
91
|
+
// alone is not enough. Both lookups are lazy + memoized: they are only
|
|
92
|
+
// ever consulted on the stale path, and the epoch lookup is shared
|
|
93
|
+
// with sources-fresh-future via getDocCommitEpochShared so the two
|
|
94
|
+
// rules don't each spawn their own `git log` for the same doc in one
|
|
95
|
+
// `check` run.
|
|
96
|
+
//
|
|
97
|
+
// GIT PROCESS BUDGET, per doc, per `check` run, with `--dirty-as-now`
|
|
98
|
+
// OFF (this figure is measured with the flag off; see below for its
|
|
99
|
+
// extra cost with the flag on): 1 (the shared `git log -1
|
|
100
|
+
// --format=%ct` epoch lookup, always) + at most 4 more on the
|
|
101
|
+
// re-stamp path (`git log -1 --format=%H%n%P`, then `git diff-tree`,
|
|
102
|
+
// then two `git show`s), i.e. AT MOST 5 -- regardless of how many
|
|
103
|
+
// sources the doc declares, since both lookups are memoized per doc.
|
|
104
|
+
// A doc created by its last commit (or by the repo's root commit)
|
|
105
|
+
// stops after 2 (or 1). Plus one `git log` per UNIQUE source path
|
|
106
|
+
// across the whole bundle (commitEpochCache above). Pinned by
|
|
107
|
+
// "spends at most five git processes per doc" in
|
|
108
|
+
// test/sources-fresh.test.ts. PLUS: at most 1 `git rev-parse
|
|
109
|
+
// --is-shallow-repository` for the ENTIRE run, not per doc (see
|
|
110
|
+
// isShallowRepoShared below) -- only spent at all when some doc's
|
|
111
|
+
// re-stamp lookup actually reaches a root commit.
|
|
112
|
+
//
|
|
113
|
+
// With `--dirty-as-now` ON, add 2 for the ENTIRE run (not per doc,
|
|
114
|
+
// not per source -- see readDirtyState): 1 `git status --porcelain`
|
|
115
|
+
// and 1 `git rev-parse --show-prefix`. Plus, PER DOC, at most 1 `git
|
|
116
|
+
// ls-tree HEAD -- <doc>` (and, only when the doc already existed at
|
|
117
|
+
// HEAD, one more `git show HEAD:<doc>`), spent only when that doc is
|
|
118
|
+
// itself dirty AND some source of its reads stale (see restampFor
|
|
119
|
+
// below).
|
|
120
|
+
const repoRelDocPath = toRepoRelDocPath(repoRoot, ctx.bundleDir, doc);
|
|
121
|
+
const docCommitEpochFor = () => getDocCommitEpochShared(ctx, git, repoRoot, repoRelDocPath);
|
|
122
|
+
// The single decision point for "did the doc's last commit -- real or
|
|
123
|
+
// virtual -- actually re-stamp it": a dirty doc (under
|
|
124
|
+
// `--dirty-as-now`) is judged by its WORKING-TREE value against the
|
|
125
|
+
// value committed at HEAD (`dirtyDocRestampVerdict`, the virtual
|
|
126
|
+
// commit's implicit "first parent" is simply HEAD); a doc that is not
|
|
127
|
+
// itself dirty is judged the ordinary way, against its real last
|
|
128
|
+
// commit's first parent (`restampedByOwnLastCommit`). This replaces
|
|
129
|
+
// the round-2 approach of a SEPARATE rescue branch checked before the
|
|
130
|
+
// co-commit path: once `docCommitEpochFor` above already reports the
|
|
131
|
+
// dirty doc's epoch as "now" (see `commitEpochWithDirtyAsNow`), the
|
|
132
|
+
// ordinary `docCommitEpoch >= commitEpoch` gate below fires for a
|
|
133
|
+
// dirty doc exactly the way it fires for a real co-commit, so only
|
|
134
|
+
// ONE verdict function needs to be picked here, not two independent
|
|
135
|
+
// mechanisms answering overlapping questions.
|
|
136
|
+
let restampMemo;
|
|
137
|
+
const restampFor = () => {
|
|
138
|
+
if (restampMemo !== undefined)
|
|
139
|
+
return restampMemo;
|
|
140
|
+
restampMemo =
|
|
141
|
+
ctx.dirtyAsNow && isDirtyForShared(ctx, git, repoRoot, repoRelDocPath)
|
|
142
|
+
? dirtyDocRestampVerdict(git, repoRoot, repoRelDocPath, getTimestampIdentity(doc.frontmatter.parsed))
|
|
143
|
+
: restampedByOwnLastCommit(git, repoRoot, repoRelDocPath, () => isShallowRepoShared(ctx, git, repoRoot));
|
|
144
|
+
return restampMemo;
|
|
145
|
+
};
|
|
146
|
+
// At most one "not assessable" notice per doc, however many of its
|
|
147
|
+
// sources hit the unanswerable re-stamp question.
|
|
148
|
+
let notAssessableReported = false;
|
|
63
149
|
for (const source of sources) {
|
|
64
150
|
// A missing path on disk is sources-shape's job to report; avoid a
|
|
65
151
|
// duplicate/confusing finding here.
|
|
@@ -76,13 +162,37 @@ export const sourcesFreshRule = {
|
|
|
76
162
|
continue;
|
|
77
163
|
}
|
|
78
164
|
let isStale = commitEpoch > timestampEpoch;
|
|
79
|
-
// Doc committed at/after the source: not stale (see comment above).
|
|
80
|
-
// A doc without git history (null epoch, e.g. uncommitted) keeps the
|
|
81
|
-
// frontmatter-only comparison.
|
|
82
165
|
if (isStale) {
|
|
166
|
+
// Doc committed (or, under `--dirty-as-now`, virtually committed
|
|
167
|
+
// right now) at/after the source, AND that commit actually
|
|
168
|
+
// re-stamped it: not stale (see comment above). A doc without git
|
|
169
|
+
// history (null epoch, e.g. uncommitted and clean, no dirty
|
|
170
|
+
// status at all) keeps the frontmatter-only comparison. When git
|
|
171
|
+
// cannot answer the re-stamp question at all, the doc is reported
|
|
172
|
+
// as not assessable rather than being guessed either way: calling
|
|
173
|
+
// it STALE would turn a git hiccup into a red build, and calling
|
|
174
|
+
// it fresh would be a silent pass.
|
|
83
175
|
const docCommitEpoch = docCommitEpochFor();
|
|
84
176
|
if (docCommitEpoch !== null && docCommitEpoch >= commitEpoch) {
|
|
85
|
-
|
|
177
|
+
const verdict = restampFor();
|
|
178
|
+
if (verdict === "restamped") {
|
|
179
|
+
isStale = false;
|
|
180
|
+
}
|
|
181
|
+
else if (verdict === "unknown" ||
|
|
182
|
+
verdict === "unknown-shallow-root") {
|
|
183
|
+
if (!notAssessableReported) {
|
|
184
|
+
notAssessableReported = true;
|
|
185
|
+
findings.push({
|
|
186
|
+
ruleId: RULE_ID,
|
|
187
|
+
severity: "notice",
|
|
188
|
+
file: doc.relPath,
|
|
189
|
+
message: verdict === "unknown-shallow-root"
|
|
190
|
+
? "staleness not assessable: this is a shallow clone (`git clone --depth`), so git cannot tell whether the doc's earliest available commit really created it or is just where history was cut off -- use `fetch-depth: 0` (or an unshallow checkout) to assess it"
|
|
191
|
+
: "staleness not assessable: git could not read the doc's own last commit to decide whether it re-stamped the doc",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
86
196
|
}
|
|
87
197
|
}
|
|
88
198
|
if (isStale) {
|
|
@@ -98,6 +208,109 @@ export const sourcesFreshRule = {
|
|
|
98
208
|
return findings;
|
|
99
209
|
},
|
|
100
210
|
};
|
|
211
|
+
/**
|
|
212
|
+
* Complements `sources-fresh`'s "too old" check with the opposite direction:
|
|
213
|
+
* a doc `timestamp` that is later than the doc file's OWN last commit (past
|
|
214
|
+
* a small clock-skew allowance) is almost always a mistake, not a real
|
|
215
|
+
* future date -- typically a local wall-clock time hand-written with a
|
|
216
|
+
* trailing `Z`/UTC suffix it does not actually have. Unlike `sources-fresh`,
|
|
217
|
+
* this check never looks at `sources` commit times at all: it only compares
|
|
218
|
+
* the doc's own `timestamp` against the doc file's own git history, so it
|
|
219
|
+
* has nothing to say about whether any source is stale.
|
|
220
|
+
*
|
|
221
|
+
* Deliberately assessed for the SAME population as `sources-fresh` (docs
|
|
222
|
+
* with a validly-shaped `sources` list and a repo root available): a
|
|
223
|
+
* `timestamp` only has "last verified against sources" semantics for a doc
|
|
224
|
+
* that declares `sources` (see the package README's authoring guidance), so
|
|
225
|
+
* a sourceless doc is out of scope for both freshness rules, not just this
|
|
226
|
+
* one. It shares `sources-fresh`'s "staleness unknown" posture for the two
|
|
227
|
+
* cases that make a real answer impossible: no repo root (silently defers
|
|
228
|
+
* to the single bundle-level notice `sources-fresh` already emits above,
|
|
229
|
+
* rather than duplicating it) and no valid `timestamp` (`sources-fresh`
|
|
230
|
+
* already reports that per-doc notice, so this rule silently skips such a
|
|
231
|
+
* doc rather than reporting it twice). An uncommitted doc (no own commit
|
|
232
|
+
* yet) is likewise "unknown, not flagged" by DEFAULT: there is no real
|
|
233
|
+
* commit time to compare the timestamp against, and flagging every
|
|
234
|
+
* hand-authored, not-yet-committed doc as "future-dated" would be a false
|
|
235
|
+
* positive on every fresh draft. Under `--dirty-as-now` (`ctx.dirtyAsNow`),
|
|
236
|
+
* this changes: `getDocCommitEpochShared` below substitutes the shared
|
|
237
|
+
* virtual-commit "now" instant for ANY dirty doc's epoch (untracked
|
|
238
|
+
* included -- see `commitEpochWithDirtyAsNow`), so a dirty doc DOES get a
|
|
239
|
+
* real comparison here, exactly the working-tree parity `--dirty-as-now`
|
|
240
|
+
* exists for: a `timestamp` re-stamped to "now" reads fresh (within the
|
|
241
|
+
* skew allowance), while one still carrying an implausible future value
|
|
242
|
+
* gets caught before the commit that would otherwise make it so in CI.
|
|
243
|
+
*/
|
|
244
|
+
export const sourcesFreshFutureRule = {
|
|
245
|
+
id: FUTURE_RULE_ID,
|
|
246
|
+
description: "A doc's frontmatter `timestamp` must not be later than the doc file's own last commit time by more than a clock-skew allowance (default 10 minutes, `--future-skew-minutes`); catches a local time mistakenly written with a `Z`/UTC suffix. Skipped (notice) for a timestamp with no explicit UTC designator (`Z`) or numeric offset, since that parses in the local timezone and cannot be compared reliably against a minutes-wide allowance. Assessed for the same docs as `sources-fresh` (a `sources` list and a repo root); see the README's \"Staleness (sources-fresh)\" section for how the two rules relate.",
|
|
247
|
+
run(ctx) {
|
|
248
|
+
const findings = [];
|
|
249
|
+
const docsWithSources = getDocsWithSources(ctx);
|
|
250
|
+
if (docsWithSources.length === 0)
|
|
251
|
+
return findings;
|
|
252
|
+
if (!ctx.repoRoot)
|
|
253
|
+
return findings;
|
|
254
|
+
const repoRoot = ctx.repoRoot;
|
|
255
|
+
const git = ctx.runGit ?? defaultRunGit;
|
|
256
|
+
const skewSeconds = ctx.freshnessFutureSkewSeconds ?? DEFAULT_FUTURE_SKEW_SECONDS;
|
|
257
|
+
for (const { doc } of docsWithSources) {
|
|
258
|
+
const timestampEpoch = getTimestampEpoch(doc.frontmatter.parsed);
|
|
259
|
+
if (timestampEpoch === undefined)
|
|
260
|
+
continue;
|
|
261
|
+
// A string timestamp with no `Z`/numeric-offset suffix parses in the
|
|
262
|
+
// machine's local timezone (`Date.parse`), which would make this
|
|
263
|
+
// check's verdict swing by hours between machines against a
|
|
264
|
+
// minutes-wide allowance -- not usable. A native `Date` frontmatter
|
|
265
|
+
// value (see getTimestampEpoch's doc comment) carries no such
|
|
266
|
+
// ambiguity and always passes this gate. sources-fresh's own
|
|
267
|
+
// thresholds are in days, wide enough that this ambiguity doesn't
|
|
268
|
+
// practically matter there, so this gate is deliberately NOT applied
|
|
269
|
+
// to that rule.
|
|
270
|
+
const rawTimestamp = getRawTimestampString(doc.frontmatter.parsed);
|
|
271
|
+
if (rawTimestamp !== undefined && !hasUtcDesignator(rawTimestamp)) {
|
|
272
|
+
findings.push({
|
|
273
|
+
ruleId: FUTURE_RULE_ID,
|
|
274
|
+
severity: "notice",
|
|
275
|
+
file: doc.relPath,
|
|
276
|
+
message: "future-dated check skipped: timestamp has no UTC designator (`Z`) or numeric offset, can't be compared reliably across timezones",
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const repoRelDocPath = toRepoRelDocPath(repoRoot, ctx.bundleDir, doc);
|
|
281
|
+
const docCommitEpoch = getDocCommitEpochShared(ctx, git, repoRoot, repoRelDocPath);
|
|
282
|
+
if (docCommitEpoch === null)
|
|
283
|
+
continue;
|
|
284
|
+
if (timestampEpoch > docCommitEpoch + skewSeconds) {
|
|
285
|
+
findings.push({
|
|
286
|
+
ruleId: FUTURE_RULE_ID,
|
|
287
|
+
severity: "warning",
|
|
288
|
+
file: doc.relPath,
|
|
289
|
+
message: `FUTURE-DATED: doc timestamp ${epochToIso(timestampEpoch)} is after the doc's own last commit ${epochToIso(docCommitEpoch)} (skew allowance ${skewSeconds}s)`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return findings;
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Docs carrying a validly-shaped frontmatter `sources` list (see
|
|
298
|
+
* `getValidSources`), each paired with that list. Shared by both rules in
|
|
299
|
+
* this file: `sources-fresh` and `sources-fresh-future` assess the same
|
|
300
|
+
* doc population, just in opposite time directions.
|
|
301
|
+
*/
|
|
302
|
+
function getDocsWithSources(ctx) {
|
|
303
|
+
return ctx.docs
|
|
304
|
+
.map((doc) => ({ doc, sources: getValidSources(doc.frontmatter.parsed) }))
|
|
305
|
+
.filter((entry) => entry.sources !== undefined);
|
|
306
|
+
}
|
|
307
|
+
/** `doc`'s own path, relative to `repoRoot`, forward-slash separated -- the pathspec `git log` needs. */
|
|
308
|
+
function toRepoRelDocPath(repoRoot, bundleDir, doc) {
|
|
309
|
+
return path
|
|
310
|
+
.relative(repoRoot, path.join(bundleDir, doc.relPath))
|
|
311
|
+
.split(path.sep)
|
|
312
|
+
.join("/");
|
|
313
|
+
}
|
|
101
314
|
/**
|
|
102
315
|
* Last-commit epoch (seconds) for `source` relative to `repoRoot`, or null
|
|
103
316
|
* when the path has no git history (untracked) or the git call itself
|
|
@@ -105,6 +318,10 @@ export const sourcesFreshRule = {
|
|
|
105
318
|
* empty stdout, which is exactly the "untracked" case, distinct from a real
|
|
106
319
|
* git failure (which RunGit also reports as null): both collapse to null
|
|
107
320
|
* here because sources-fresh treats them the same way, "staleness unknown".
|
|
321
|
+
* Uses committer time (`%ct`), not author time (`%at`): a rebase or
|
|
322
|
+
* cherry-pick can carry a stale author date forward while the committer
|
|
323
|
+
* date reflects when the content actually landed on this branch, which is
|
|
324
|
+
* what both freshness rules care about.
|
|
108
325
|
*/
|
|
109
326
|
function getLastCommitEpoch(git, repoRoot, source) {
|
|
110
327
|
const out = git(["log", "-1", "--format=%ct", "--", source], repoRoot);
|
|
@@ -113,7 +330,567 @@ function getLastCommitEpoch(git, repoRoot, source) {
|
|
|
113
330
|
const epoch = Number.parseInt(out, 10);
|
|
114
331
|
return Number.isNaN(epoch) ? null : epoch;
|
|
115
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* The full SET of repo-root-relative paths `git status --porcelain`
|
|
335
|
+
* reports as dirty (modified, staged, or untracked) across the WHOLE work
|
|
336
|
+
* tree, read via a SINGLE `status` process for the entire `check` run --
|
|
337
|
+
* not one process per unique `sources` path (the original, pre-review
|
|
338
|
+
* implementation), and not one per doc for the local-restamp rescue
|
|
339
|
+
* below. Used only behind `--dirty-as-now` (`ctx.dirtyAsNow`): a dirty
|
|
340
|
+
* path has no "last commit" yet that reflects its current content, so
|
|
341
|
+
* `sources-fresh` substitutes the current time for it instead of falling
|
|
342
|
+
* through to `getLastCommitEpoch`'s answer (the LAST commit's time, which
|
|
343
|
+
* for a locally-edited-but-uncommitted file is necessarily stale).
|
|
344
|
+
*
|
|
345
|
+
* `--no-optional-locks` skips git's opportunistic index refresh (which can
|
|
346
|
+
* otherwise rewrite the on-disk index file as a side effect of a plain
|
|
347
|
+
* `status`) -- unwanted here since this runs as a read-only check, often
|
|
348
|
+
* from a hook or CI step that should not perturb the index. Ignored files
|
|
349
|
+
* are deliberately NOT included (no `--ignored`): an ignored source keeps
|
|
350
|
+
* today's "untracked by git, staleness unknown" notice instead of being
|
|
351
|
+
* newly reported dirty by this option.
|
|
352
|
+
*
|
|
353
|
+
* `--untracked-files=all` (`-uall`) is NOT optional decoration. Under
|
|
354
|
+
* git's default untracked mode (`normal`), a brand-new directory is
|
|
355
|
+
* collapsed into ONE record for the directory itself (`? newdir/`) and the
|
|
356
|
+
* files inside it are never listed. The matcher below matches a queried
|
|
357
|
+
* path that IS a reported entry or is an ANCESTOR of one, so under that
|
|
358
|
+
* default a `sources` entry (or a doc) INSIDE a new untracked directory
|
|
359
|
+
* matched nothing at all and silently kept its committed-history verdict:
|
|
360
|
+
* `check --dirty-as-now` reported a brand-new source as merely `untracked
|
|
361
|
+
* by git, staleness unknown` (exit 0) while a plain `check` after
|
|
362
|
+
* committing it reported STALE (exit 1) -- the precise local/CI divergence
|
|
363
|
+
* this flag exists to remove. `-uall` makes git enumerate every untracked
|
|
364
|
+
* FILE as its own record, so the ordinary exact/descendant matching sees
|
|
365
|
+
* them. The cost is output size: `-uall` prints one record per untracked
|
|
366
|
+
* file rather than per untracked directory, which in a work tree carrying
|
|
367
|
+
* a large untracked build or dependency tree (anything not covered by
|
|
368
|
+
* `.gitignore` -- ignored paths are still excluded, see above) can run to
|
|
369
|
+
* many thousands of records. That output travels through RunGit's 16 MiB
|
|
370
|
+
* cap (`MAX_GIT_OUTPUT_BYTES` in src/git.ts), and a status that exceeds it
|
|
371
|
+
* resolves to null like any other git failure -- reported as the
|
|
372
|
+
* bundle-level "not applied" notice the rule emits, never as a silent
|
|
373
|
+
* "nothing is dirty".
|
|
374
|
+
*
|
|
375
|
+
* `git rev-parse --show-prefix` is the SECOND (and last) git process this
|
|
376
|
+
* costs per run. `git status` always reports paths relative to the
|
|
377
|
+
* repository's TOP LEVEL, while a `sources` entry is relative to
|
|
378
|
+
* `ctx.repoRoot` -- the same directory only when `--repo-root` names the
|
|
379
|
+
* top level. Pointed at a SUBDIRECTORY (`--repo-root packages/foo`), every
|
|
380
|
+
* status path carries a `packages/foo/` prefix the queried path does not,
|
|
381
|
+
* so nothing ever matched and the flag silently did nothing. Reading the
|
|
382
|
+
* prefix once and prepending it at match time makes that case work instead
|
|
383
|
+
* of quietly no-opping.
|
|
384
|
+
*
|
|
385
|
+
* Uses `--porcelain=v2 -z`, not the more familiar `--porcelain` (v1) line
|
|
386
|
+
* format, for one reason that is NOT about renames or quoting (`-z` alone
|
|
387
|
+
* would fix both): RunGit (src/git.ts) `.trim()`s every git invocation's
|
|
388
|
+
* output, and a v1 line for an unstaged-only change starts with a LITERAL
|
|
389
|
+
* SPACE (` M path`, index clean, worktree modified) -- an entirely common
|
|
390
|
+
* case. When that happens to be the FIRST line of the whole status output,
|
|
391
|
+
* `.trim()` strips that leading space as ordinary boundary whitespace,
|
|
392
|
+
* silently shifting every fixed-offset field in that one line and
|
|
393
|
+
* corrupting its path (verified against real git output: a lone `M
|
|
394
|
+
* source.ts` line, meant to be ` M source.ts`, parses to the wrong path
|
|
395
|
+
* with a fixed `line.slice(3)` cut). Every v2 record instead starts with a
|
|
396
|
+
* digit or letter (`1`, `2`, `u`, `?`) that is never whitespace, so the
|
|
397
|
+
* SAME `.trim()` can never eat a leading field byte. NUL (`-z`) delimits
|
|
398
|
+
* every record and untracked/rename path field can safely contain a space,
|
|
399
|
+
* tab, or non-ASCII byte, mirroring previousPathIn's `-z` rationale
|
|
400
|
+
* elsewhere in this file. Record shapes actually seen here (verified
|
|
401
|
+
* against real git output; XY, sub, mode and hash fields are read
|
|
402
|
+
* positionally, never re-split by content, since a path can itself
|
|
403
|
+
* legitimately contain a space): `? path` (untracked); `1 XY sub mH mI mW
|
|
404
|
+
* hH hI path` (ordinary; 8 fixed fields before path); `2 XY sub mH mI mW
|
|
405
|
+
* hH hI score path\0origPath` (rename/copy; 9 fixed fields before path,
|
|
406
|
+
* origPath is the FOLLOWING NUL-delimited token verbatim); `u XY sub m1 m2
|
|
407
|
+
* m3 mW h1 h2 h3 path` (unmerged; 10 fixed fields before path) -- both
|
|
408
|
+
* sides of a rename count as dirty. `!` (ignored) records are never
|
|
409
|
+
* produced since `--ignored` is not passed.
|
|
410
|
+
*
|
|
411
|
+
* A failed git call (either one: `null`) makes the WHOLE state null, which
|
|
412
|
+
* every path then falls through on -- it keeps its ordinary commit-epoch
|
|
413
|
+
* verdict rather than this function inventing a dirty one. Unlike every
|
|
414
|
+
* other git failure in this file that is silent, this one is surfaced: the
|
|
415
|
+
* rule turns a null state into a single bundle-level "`--dirty-as-now` not
|
|
416
|
+
* applied" notice, because "the flag silently did nothing" and "nothing
|
|
417
|
+
* was dirty" are indistinguishable to a reader of an empty finding list.
|
|
418
|
+
*/
|
|
419
|
+
function readDirtyState(git, repoRoot) {
|
|
420
|
+
// `""` (this IS the top level) is a valid answer, so only an outright
|
|
421
|
+
// failure (`null`) disables the flag here.
|
|
422
|
+
const rawPrefix = git(["rev-parse", "--show-prefix"], repoRoot);
|
|
423
|
+
if (rawPrefix === null)
|
|
424
|
+
return null;
|
|
425
|
+
const prefix = rawPrefix === "" ? "" : `${normalizeRelPath(rawPrefix)}/`;
|
|
426
|
+
const out = git([
|
|
427
|
+
"--no-optional-locks",
|
|
428
|
+
"status",
|
|
429
|
+
"--porcelain=v2",
|
|
430
|
+
"-z",
|
|
431
|
+
"--untracked-files=all",
|
|
432
|
+
], repoRoot);
|
|
433
|
+
if (out === null)
|
|
434
|
+
return null;
|
|
435
|
+
const paths = new Set();
|
|
436
|
+
// `-z` NUL-terminates every record, including the last -- RunGit's
|
|
437
|
+
// `.trim()` does not strip NUL, so splitting always leaves one empty
|
|
438
|
+
// trailing token.
|
|
439
|
+
const tokens = out.split("\0").filter((t) => t !== "");
|
|
440
|
+
let i = 0;
|
|
441
|
+
while (i < tokens.length) {
|
|
442
|
+
const token = tokens[i];
|
|
443
|
+
const kind = token[0];
|
|
444
|
+
if (kind === "?") {
|
|
445
|
+
paths.add(normalizeRelPath(token.slice(2)));
|
|
446
|
+
i += 1;
|
|
447
|
+
}
|
|
448
|
+
else if (kind === "1") {
|
|
449
|
+
paths.add(normalizeRelPath(token.split(" ").slice(8).join(" ")));
|
|
450
|
+
i += 1;
|
|
451
|
+
}
|
|
452
|
+
else if (kind === "2") {
|
|
453
|
+
paths.add(normalizeRelPath(token.split(" ").slice(9).join(" ")));
|
|
454
|
+
const origPath = tokens[i + 1];
|
|
455
|
+
if (origPath !== undefined)
|
|
456
|
+
paths.add(normalizeRelPath(origPath));
|
|
457
|
+
i += 2;
|
|
458
|
+
}
|
|
459
|
+
else if (kind === "u") {
|
|
460
|
+
paths.add(normalizeRelPath(token.split(" ").slice(10).join(" ")));
|
|
461
|
+
i += 1;
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
// An unrecognized record kind (should not occur without --ignored,
|
|
465
|
+
// which is never passed): skip rather than mis-parse it as a path.
|
|
466
|
+
i += 1;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return { prefix, paths };
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Canonicalizes a repo-relative path spelling before it is used as a dirty
|
|
473
|
+
* lookup key or matched against one: strips a leading `./`, strips a
|
|
474
|
+
* trailing `/`, converts backslashes to forward slashes, and collapses `.`
|
|
475
|
+
* / `..` segments and duplicate slashes via `path.posix.normalize`. `git
|
|
476
|
+
* status` itself always reports canonical forward-slash paths with neither
|
|
477
|
+
* a leading `./` nor a trailing `/`, but a frontmatter-authored `sources`
|
|
478
|
+
* entry (or a doc's own bundle-relative path, joined with `path.join`) is
|
|
479
|
+
* under no such obligation -- `./srcdir` and `srcdir/` name the exact same
|
|
480
|
+
* directory `srcdir` does, and MUST match the same dirty entries it does.
|
|
481
|
+
* Applied on BOTH sides of every dirty-path comparison in this file (here,
|
|
482
|
+
* at insertion into the dirty-paths Set, and in `isDirtyForShared` on the
|
|
483
|
+
* queried path already joined with the repo prefix), so any spelling of
|
|
484
|
+
* the same path always matches.
|
|
485
|
+
*
|
|
486
|
+
* `.`, `./` and the empty string all normalize to `.`, the root directory
|
|
487
|
+
* itself. That is a legal `sources` spelling but never a `git status`
|
|
488
|
+
* entry, so `isDirtyForShared` answers it by containment instead of by
|
|
489
|
+
* lookup -- see the `normalized === "."` branch there.
|
|
490
|
+
*
|
|
491
|
+
* A round-1-then-round-2 regression this closes: the per-run status
|
|
492
|
+
* refactor introduced exact-path (`paths.has`) and prefix (`startsWith`)
|
|
493
|
+
* matching against the RAW `source` string, so `srcdir/` built a
|
|
494
|
+
* self-defeating double-slash prefix (`srcdir//`) and `./srcdir` never
|
|
495
|
+
* matched a `git status` path at all (which never carries a `./` prefix) --
|
|
496
|
+
* both spellings silently lost dirty detection entirely.
|
|
497
|
+
*/
|
|
498
|
+
function normalizeRelPath(relPath) {
|
|
499
|
+
let p = relPath.replace(/\\/g, "/");
|
|
500
|
+
while (p.startsWith("./"))
|
|
501
|
+
p = p.slice(2);
|
|
502
|
+
p = path.posix.normalize(p);
|
|
503
|
+
while (p.length > 1 && p.endsWith("/"))
|
|
504
|
+
p = p.slice(0, -1);
|
|
505
|
+
return p;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Per-run cache (keyed by `BundleContext`, same pattern as
|
|
509
|
+
* `docCommitEpochCache` below) of `readDirtyState`'s answer, so the WHOLE
|
|
510
|
+
* `check` run -- both rules, every doc, every source -- spends at most one
|
|
511
|
+
* `git status` (plus one `git rev-parse --show-prefix`) process, not one
|
|
512
|
+
* per rule. A cached `null` (the git read failed) is a real cached answer,
|
|
513
|
+
* hence the `.has` check rather than a truthiness test: a failed read is
|
|
514
|
+
* not retried per source.
|
|
515
|
+
*/
|
|
516
|
+
const dirtyStateCache = new WeakMap();
|
|
517
|
+
function getDirtyStateShared(ctx, git, repoRoot) {
|
|
518
|
+
if (dirtyStateCache.has(ctx))
|
|
519
|
+
return dirtyStateCache.get(ctx) ?? null;
|
|
520
|
+
const state = readDirtyState(git, repoRoot);
|
|
521
|
+
dirtyStateCache.set(ctx, state);
|
|
522
|
+
return state;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Whether `relPath` (a `sources` entry OR a doc's own path relative to
|
|
526
|
+
* `ctx.repoRoot`, in any spelling `normalizeRelPath` accepts) is dirty per
|
|
527
|
+
* `git status`. The queried path is first rebased onto the repository top
|
|
528
|
+
* level with `state.prefix` (a no-op in the ordinary case where
|
|
529
|
+
* `--repo-root` IS the top level), since that is the frame every reported
|
|
530
|
+
* dirty path is expressed in. A dirty FILE then matches by exact
|
|
531
|
+
* (normalized) path; a dirty DIRECTORY source matches if any reported
|
|
532
|
+
* dirty path lives under it (a `/`-terminated prefix, so a sibling
|
|
533
|
+
* directory whose name merely starts with the same characters -- `srcdir`
|
|
534
|
+
* vs `srcdirX/x.ts` -- never falsely matches).
|
|
535
|
+
*
|
|
536
|
+
* `.` (or `./`, or any spelling that normalizes to `.`) names the root
|
|
537
|
+
* directory itself, which `git status` never reports as an entry and which
|
|
538
|
+
* no `"./"`-prefix test can match either -- so it is matched explicitly as
|
|
539
|
+
* "any dirty path at all lives under this root", the same containment
|
|
540
|
+
* question the directory case asks, just with an empty prefix. Without
|
|
541
|
+
* that branch a bundle declaring `.` as its source silently lost dirty
|
|
542
|
+
* detection entirely.
|
|
543
|
+
*
|
|
544
|
+
* A failed state read (`null`) is treated as "nothing is dirty": every
|
|
545
|
+
* path falls through to its ordinary (real) commit-epoch path rather than
|
|
546
|
+
* this function inventing a dirty verdict either way. The rule reports
|
|
547
|
+
* that case once, bundle-level, rather than letting the flag no-op in
|
|
548
|
+
* silence (see `readDirtyState`).
|
|
549
|
+
*/
|
|
550
|
+
function isDirtyForShared(ctx, git, repoRoot, relPath) {
|
|
551
|
+
const state = getDirtyStateShared(ctx, git, repoRoot);
|
|
552
|
+
if (state === null)
|
|
553
|
+
return false;
|
|
554
|
+
const { paths } = state;
|
|
555
|
+
const normalized = normalizeRelPath(`${state.prefix}${relPath}`);
|
|
556
|
+
if (normalized === ".")
|
|
557
|
+
return paths.size > 0;
|
|
558
|
+
if (paths.has(normalized))
|
|
559
|
+
return true;
|
|
560
|
+
const prefix = `${normalized}/`;
|
|
561
|
+
for (const p of paths) {
|
|
562
|
+
if (p.startsWith(prefix))
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Per-run cache (keyed by `BundleContext`) of the SINGLE "now" instant
|
|
569
|
+
* `--dirty-as-now` substitutes for every dirty path's commit epoch, shared
|
|
570
|
+
* across BOTH rules in this file and every doc/source they assess: every
|
|
571
|
+
* dirty path in one `check` invocation is treated as landing in the exact
|
|
572
|
+
* same virtual commit, so two dirty sources (or a dirty source and a dirty
|
|
573
|
+
* doc) never disagree with each other about what "now" was.
|
|
574
|
+
*
|
|
575
|
+
* THE CACHE IS THE GUARANTEE, not the order the callers happen to run in.
|
|
576
|
+
* The rescue gate above is an INEQUALITY on epochs (`docCommitEpoch >=
|
|
577
|
+
* commitEpoch`) whose two sides are BOTH this value when a dirty doc is
|
|
578
|
+
* paired with a dirty source. Reading the clock afresh on each side
|
|
579
|
+
* instead would leave the gate resting on call order: as the rule is
|
|
580
|
+
* written today the source's epoch is read before the doc's
|
|
581
|
+
* (`commitEpochFor`, then `docCommitEpochFor`), so a second-boundary
|
|
582
|
+
* crossing between them could only ever raise the DOC's side and the gate
|
|
583
|
+
* would survive by accident -- but read in the other order (a reordering
|
|
584
|
+
* nothing in the rule forbids) the doc's side would be the older one, and
|
|
585
|
+
* a run that straddled a whole second would flip to a silent,
|
|
586
|
+
* unreproducible STALE. The cache removes the ordering question entirely:
|
|
587
|
+
* every dirty path in one run reads the identical integer, so the gate
|
|
588
|
+
* holds with equality no matter who asks first. Any future caller wanting
|
|
589
|
+
* "now" here must therefore go through this accessor rather than reading
|
|
590
|
+
* the clock again.
|
|
591
|
+
*/
|
|
592
|
+
const nowEpochCache = new WeakMap();
|
|
593
|
+
function getNowEpochShared(ctx) {
|
|
594
|
+
let epoch = nowEpochCache.get(ctx);
|
|
595
|
+
if (epoch === undefined) {
|
|
596
|
+
epoch = Math.floor(Date.now() / 1000);
|
|
597
|
+
nowEpochCache.set(ctx, epoch);
|
|
598
|
+
}
|
|
599
|
+
return epoch;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* THE single choke point where `--dirty-as-now`'s virtual-commit model is
|
|
603
|
+
* applied: a dirty `relPath` (source or doc, see `isDirtyForShared`) is
|
|
604
|
+
* judged as committed at the shared "now" instant (`getNowEpochShared`)
|
|
605
|
+
* instead of consulting `actualEpoch` at all; anything else -- the flag
|
|
606
|
+
* off, or `relPath` not dirty -- defers to `actualEpoch` (typically a
|
|
607
|
+
* memoized real `git log` lookup), byte-identical to pre-flag behavior.
|
|
608
|
+
* Every commit-epoch read in this file that is meant to honor
|
|
609
|
+
* `--dirty-as-now` MUST go through this function (directly, or via
|
|
610
|
+
* `getDocCommitEpochShared` below, which is itself built on it) rather
|
|
611
|
+
* than re-implementing the dirty check inline, so a source's epoch and a
|
|
612
|
+
* doc's own epoch are always read the same way, by both rules.
|
|
613
|
+
*/
|
|
614
|
+
function commitEpochWithDirtyAsNow(ctx, git, repoRoot, relPath, actualEpoch) {
|
|
615
|
+
if (ctx.dirtyAsNow && isDirtyForShared(ctx, git, repoRoot, relPath)) {
|
|
616
|
+
return getNowEpochShared(ctx);
|
|
617
|
+
}
|
|
618
|
+
return actualEpoch();
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* `--dirty-as-now` only: the verdict for "did the doc's OWN uncommitted
|
|
622
|
+
* working-tree state re-stamp it" -- the virtual-commit analogue of
|
|
623
|
+
* `restampedByOwnLastCommit`'s co-commit check, used in its place (see
|
|
624
|
+
* `restampFor` in the rule above) whenever the doc itself is dirty. The
|
|
625
|
+
* virtual commit's implicit "first parent" is simply HEAD, so this compares
|
|
626
|
+
* the doc's CURRENT (working-tree) parsed `timestamp` identity -- already
|
|
627
|
+
* read from disk by the caller, since `BundleDoc` always reflects the
|
|
628
|
+
* working tree, dirty or not -- against the value committed at HEAD for the
|
|
629
|
+
* same path. Any change of value, in EITHER direction (including a
|
|
630
|
+
* backwards re-stamp), counts as re-stamped, mirroring the committed
|
|
631
|
+
* rescue's "changed, not corrected" semantics documented on
|
|
632
|
+
* `restampedByOwnLastCommit` below.
|
|
633
|
+
*
|
|
634
|
+
* Distinguishes a genuinely untracked/new doc (no entry for this path at
|
|
635
|
+
* HEAD at all -- `git ls-tree HEAD -- <path>` succeeds with EMPTY output,
|
|
636
|
+
* never a git failure) from a real git failure reading an EXISTING HEAD
|
|
637
|
+
* blob (`git show` itself failing -- a corrupt object, an unreadable blob):
|
|
638
|
+
* the former counts as "restamped" (there is no prior committed value to
|
|
639
|
+
* compare against, so its whole content, stamp included, is new -- the
|
|
640
|
+
* working-tree equivalent of "the doc being created there"); the latter is
|
|
641
|
+
* `unknown`, falling through to the rule's existing not-assessable notice
|
|
642
|
+
* rather than being silently treated the same as "created". `git ls-tree`
|
|
643
|
+
* itself failing (an unborn HEAD, git unavailable) is likewise `unknown`.
|
|
644
|
+
*/
|
|
645
|
+
function dirtyDocRestampVerdict(git, repoRoot, repoRelDocPath, onDiskTimestamp) {
|
|
646
|
+
const treeEntry = git(["ls-tree", "-z", "HEAD", "--", repoRelDocPath], repoRoot);
|
|
647
|
+
if (treeEntry === null)
|
|
648
|
+
return "unknown";
|
|
649
|
+
if (treeEntry === "")
|
|
650
|
+
return "restamped";
|
|
651
|
+
const committed = git(["show", `HEAD:${repoRelDocPath}`], repoRoot);
|
|
652
|
+
if (committed === null)
|
|
653
|
+
return "unknown";
|
|
654
|
+
const committedStamp = getTimestampIdentity(parseFrontmatter(committed).frontmatter.parsed);
|
|
655
|
+
return committedStamp !== onDiskTimestamp ? "restamped" : "not-restamped";
|
|
656
|
+
}
|
|
116
657
|
function epochToIso(epochSeconds) {
|
|
117
658
|
return new Date(epochSeconds * 1000).toISOString();
|
|
118
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Per-run cache of a doc's own REAL (committed) last-commit epoch, keyed by
|
|
662
|
+
* the `BundleContext` instance so `sources-fresh`'s doc-commit comparison
|
|
663
|
+
* and `sources-fresh-future`'s timestamp comparison -- both need the
|
|
664
|
+
* IDENTICAL (repoRoot, doc path) `getLastCommitEpoch` lookup for every doc
|
|
665
|
+
* in one `check` invocation -- share one `git log` process per doc instead
|
|
666
|
+
* of each rule spawning its own. Safe to key on the context object itself:
|
|
667
|
+
* a fresh `BundleContext` is built per `runCheck`/`loadBundle` call, so
|
|
668
|
+
* nothing reuses a stale cache entry across invocations, and the WeakMap
|
|
669
|
+
* lets the cache be garbage-collected with the context once a run is done.
|
|
670
|
+
*
|
|
671
|
+
* `getDocCommitEpochShared` below is the single place BOTH rules read a
|
|
672
|
+
* doc's commit epoch from, and it is itself built on
|
|
673
|
+
* `commitEpochWithDirtyAsNow`: under `--dirty-as-now`, a dirty doc's epoch
|
|
674
|
+
* is the shared virtual "now" instant instead of this real cache's answer,
|
|
675
|
+
* so `sources-fresh`'s co-commit rescue and `sources-fresh-future`'s
|
|
676
|
+
* timestamp comparison always see the identical (real-or-virtual) epoch
|
|
677
|
+
* for the same doc in the same `check` run.
|
|
678
|
+
*/
|
|
679
|
+
const docCommitEpochCache = new WeakMap();
|
|
680
|
+
function getDocCommitEpochShared(ctx, git, repoRoot, repoRelDocPath) {
|
|
681
|
+
return commitEpochWithDirtyAsNow(ctx, git, repoRoot, repoRelDocPath, () => {
|
|
682
|
+
let cache = docCommitEpochCache.get(ctx);
|
|
683
|
+
if (!cache) {
|
|
684
|
+
cache = new Map();
|
|
685
|
+
docCommitEpochCache.set(ctx, cache);
|
|
686
|
+
}
|
|
687
|
+
const cached = cache.get(repoRelDocPath);
|
|
688
|
+
if (cached !== undefined)
|
|
689
|
+
return cached;
|
|
690
|
+
const epoch = getLastCommitEpoch(git, repoRoot, repoRelDocPath);
|
|
691
|
+
cache.set(repoRelDocPath, epoch);
|
|
692
|
+
return epoch;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Per-run cache (keyed by `BundleContext`, same pattern as
|
|
697
|
+
* `docCommitEpochCache` above) of whether `repoRoot` is a shallow clone
|
|
698
|
+
* (`git clone --depth <n>`), so `restampedByOwnLastCommit`'s root-commit
|
|
699
|
+
* shortcut spends at most ONE `git rev-parse --is-shallow-repository` for
|
|
700
|
+
* the whole `check` run, not one per doc. A failed git call (repoRoot
|
|
701
|
+
* somehow not a real git work tree after all) is treated as shallow: this
|
|
702
|
+
* function's only consumer only ever asks it to decide whether a commit
|
|
703
|
+
* with an empty parent list is trustworthy as a genuine root commit, and
|
|
704
|
+
* the file's standing rule is to answer "unknown" rather than invent
|
|
705
|
+
* either verdict when git itself cannot be asked -- so a failure here must
|
|
706
|
+
* NOT fall back to the previous "always trust it" behavior.
|
|
707
|
+
*/
|
|
708
|
+
const shallowRepoCache = new WeakMap();
|
|
709
|
+
function isShallowRepoShared(ctx, git, repoRoot) {
|
|
710
|
+
const cached = shallowRepoCache.get(ctx);
|
|
711
|
+
if (cached !== undefined)
|
|
712
|
+
return cached;
|
|
713
|
+
const out = git(["rev-parse", "--is-shallow-repository"], repoRoot);
|
|
714
|
+
const isShallow = out === null ? true : out.trim() === "true";
|
|
715
|
+
shallowRepoCache.set(ctx, isShallow);
|
|
716
|
+
return isShallow;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Whether `doc`'s own last commit actually re-stamped it, decided by
|
|
720
|
+
* comparing the doc's PARSED FRONTMATTER `timestamp` VALUE at that commit
|
|
721
|
+
* against its value in the commit's FIRST PARENT. A doc created by that
|
|
722
|
+
* commit (or by a genuine root commit of an unshallow repository) counts as
|
|
723
|
+
* re-stamped: its stamp arrived with it. In a SHALLOW clone (`git clone
|
|
724
|
+
* --depth`), the doc's last commit can have an EMPTY parent list purely
|
|
725
|
+
* because that is where history was grafted off, not because it is really
|
|
726
|
+
* the repo's first commit -- `isShallowRepo` (checked lazily, only when a
|
|
727
|
+
* commit with no parents is actually seen) distinguishes the two, so a
|
|
728
|
+
* shallow checkout gets `unknown-shallow-root` there instead of an assumed
|
|
729
|
+
* `restamped`.
|
|
730
|
+
*
|
|
731
|
+
* This is what narrows `sources-fresh`'s co-commit staleness exception: a
|
|
732
|
+
* commit that merely happens to also touch the doc file (a typo fix, a
|
|
733
|
+
* repo-wide formatter run, a rename) without changing the stamp carries no
|
|
734
|
+
* verification claim and must NOT suppress staleness; only a commit that
|
|
735
|
+
* actually rewrote the stamp (or created the doc) does.
|
|
736
|
+
*
|
|
737
|
+
* WHY VALUES AND NOT DIFF TEXT. An earlier version of this check scanned
|
|
738
|
+
* `git log -1 -p -- <doc>` for a `^\+timestamp:` line. Scanning diff TEXT is
|
|
739
|
+
* wrong in three distinct, independently reachable ways, each of which this
|
|
740
|
+
* value comparison closes structurally rather than by another special case:
|
|
741
|
+
*
|
|
742
|
+
* 1. A fenced YAML EXAMPLE in the doc's BODY can contain an unindented
|
|
743
|
+
* `timestamp:` line. Added in a commit that also changed a source, that
|
|
744
|
+
* body line reads as a re-stamp and silently suppresses staleness --
|
|
745
|
+
* exactly the review class this rule exists to close. Parsing
|
|
746
|
+
* frontmatter cannot see a body line at all.
|
|
747
|
+
* 2. A RENAME (`git mv`) shows as `new file mode` under a single-path
|
|
748
|
+
* `git log -p`, firing the "created counts as stamped" branch even
|
|
749
|
+
* though the stamp never moved. The rename-aware lookup below reads the
|
|
750
|
+
* doc's real previous path instead.
|
|
751
|
+
* 3. A MERGE commit prints NO patch at all under `git log -p` (git's
|
|
752
|
+
* default combined-diff suppression), so a genuine re-stamp landing on
|
|
753
|
+
* a `refs/pull/N/merge` ref -- the ref CI actually checks out -- became
|
|
754
|
+
* an invisible false-positive STALE. Both trees are perfectly readable
|
|
755
|
+
* via `git show`, merge or not.
|
|
756
|
+
*
|
|
757
|
+
* Its limits, stated rather than hidden: this answers "did the value
|
|
758
|
+
* change", never "is the new value right". A hand-typed or backdated stamp
|
|
759
|
+
* still counts as a re-stamp (`sources-fresh-future` is the rule that
|
|
760
|
+
* catches an implausible value), and comparing against only the FIRST parent
|
|
761
|
+
* means a merge that takes its doc content wholesale from the second parent
|
|
762
|
+
* is judged against the first-parent baseline, which is the same baseline
|
|
763
|
+
* the PR under review is measured against.
|
|
764
|
+
*
|
|
765
|
+
* Spends at most 4 git processes and returns early before most of them: 1
|
|
766
|
+
* for the commit + parents, 1 for the rename-aware name-status lookup
|
|
767
|
+
* (skipped for a root commit), and 2 blob reads (skipped when the doc was
|
|
768
|
+
* created there). See the GIT PROCESS BUDGET comment in the rule above.
|
|
769
|
+
*/
|
|
770
|
+
function restampedByOwnLastCommit(git, repoRoot, repoRelDocPath, isShallowRepo) {
|
|
771
|
+
// %H then %P on its own line: the doc's last commit and its parent list in
|
|
772
|
+
// ONE process. Default history simplification is exactly what this needs
|
|
773
|
+
// for a single path: a merge whose result for that path differs from every
|
|
774
|
+
// parent (a conflict resolution, or a clean auto-merge of two sides that
|
|
775
|
+
// both touched the doc) IS returned here, while a merge that is TREESAME
|
|
776
|
+
// to a parent resolves to the real content-changing commit on that side.
|
|
777
|
+
const head = git(["log", "-1", "--format=%H%n%P", "--", repoRelDocPath], repoRoot);
|
|
778
|
+
if (head === null)
|
|
779
|
+
return "unknown";
|
|
780
|
+
const [sha, parentLine] = head.split("\n");
|
|
781
|
+
if (!sha)
|
|
782
|
+
return "unknown";
|
|
783
|
+
const parents = (parentLine ?? "").split(" ").filter((p) => p !== "");
|
|
784
|
+
// An empty parent list means "the doc arrived with the repo's first
|
|
785
|
+
// commit, stamp and all" ONLY in a genuinely unshallow repository. In a
|
|
786
|
+
// shallow clone (`git clone --depth`), the boundary commit git grafted
|
|
787
|
+
// the history onto also reports an empty parent list for every path
|
|
788
|
+
// touched at or before it -- indistinguishable from a real root commit
|
|
789
|
+
// by this lookup alone -- so trusting it there would let the shallow-clone
|
|
790
|
+
// shortcut fire unconditionally and silently suppress every doc's
|
|
791
|
+
// staleness. isShallowRepo() is checked here, not unconditionally at the
|
|
792
|
+
// top of this function, so an unshallow repo never pays for it.
|
|
793
|
+
if (parents.length === 0) {
|
|
794
|
+
return isShallowRepo() ? "unknown-shallow-root" : "restamped";
|
|
795
|
+
}
|
|
796
|
+
const firstParent = parents[0];
|
|
797
|
+
const previous = previousPathIn(git, repoRoot, firstParent, sha, repoRelDocPath);
|
|
798
|
+
if (previous.kind === "unknown")
|
|
799
|
+
return "unknown";
|
|
800
|
+
if (previous.kind === "created")
|
|
801
|
+
return "restamped";
|
|
802
|
+
const current = git(["show", `${sha}:${repoRelDocPath}`], repoRoot);
|
|
803
|
+
if (current === null)
|
|
804
|
+
return "unknown";
|
|
805
|
+
const before = git(["show", `${firstParent}:${previous.path}`], repoRoot);
|
|
806
|
+
if (before === null)
|
|
807
|
+
return "unknown";
|
|
808
|
+
const currentStamp = getTimestampIdentity(parseFrontmatter(current).frontmatter.parsed);
|
|
809
|
+
const beforeStamp = getTimestampIdentity(parseFrontmatter(before).frontmatter.parsed);
|
|
810
|
+
// Both undefined (no parseable stamp on either side) compares equal, i.e.
|
|
811
|
+
// "not re-stamped" -- nothing was rewritten, so nothing is claimed.
|
|
812
|
+
return currentStamp !== beforeStamp ? "restamped" : "not-restamped";
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Where the doc lived in `parentSha`, so its previous revision can be read
|
|
816
|
+
* by that path: the same path (`same`), a different one it was renamed from
|
|
817
|
+
* (`renamed`), or nowhere at all because that commit created it (`created`).
|
|
818
|
+
*
|
|
819
|
+
* Runs `git diff-tree` WITHOUT a pathspec on purpose. A pathspec is applied
|
|
820
|
+
* BEFORE rename detection, so `git diff-tree -M --name-status <parent> <sha>
|
|
821
|
+
* -- <doc>` reports a renamed doc as `A` (verified against a real `git mv`
|
|
822
|
+
* fixture) -- which is precisely the false "created" verdict that made a
|
|
823
|
+
* rename suppress staleness. The unfiltered output is still bounded and
|
|
824
|
+
* small: `--name-status -r` prints one line per changed path, not any file
|
|
825
|
+
* content, and the raised RunGit output cap (see src/git.ts) covers even a
|
|
826
|
+
* repo-wide formatting commit.
|
|
827
|
+
*
|
|
828
|
+
* A doc that does not appear in the diff at all resolves to `same`: the two
|
|
829
|
+
* revisions are then byte-identical by construction, and the value
|
|
830
|
+
* comparison above resolves that to "not re-stamped" without a second code
|
|
831
|
+
* path deciding it.
|
|
832
|
+
*
|
|
833
|
+
* Runs with `-z` (NUL-delimited output) rather than the default newline/tab
|
|
834
|
+
* form, and for one reason that is NOT about newlines: git's default
|
|
835
|
+
* `--name-status` output C-QUOTES any path containing a non-ASCII byte
|
|
836
|
+
* (`core.quotePath` defaults to true) as a double-quoted string with octal
|
|
837
|
+
* escapes (e.g. `"bundle/\303\266lt.md"` for `bundle/ölt.md`), so a plain
|
|
838
|
+
* `repoRelDocPath` never string-equals that quoted form and a non-ASCII doc
|
|
839
|
+
* falls through every branch below to the `same` fallback, which returns
|
|
840
|
+
* the doc's real (unquoted) CURRENT path unchanged. When that commit was
|
|
841
|
+
* actually a rename or creation, the doc was NOT at that path in the first
|
|
842
|
+
* parent -- it lived under a different name there, or did not exist yet --
|
|
843
|
+
* so the caller's `git show <firstParent>:<that path>` blob read fails,
|
|
844
|
+
* turning a normal rename or creation into a false `not assessable`
|
|
845
|
+
* notice. `-z` prints every path verbatim,
|
|
846
|
+
* unquoted, regardless of `core.quotePath`, closing that structurally
|
|
847
|
+
* rather than by passing `-c core.quotePath=false` (which is a config
|
|
848
|
+
* override this rule would otherwise have to remember on every git
|
|
849
|
+
* invocation touching a path, not just this one).
|
|
850
|
+
*/
|
|
851
|
+
function previousPathIn(git, repoRoot, parentSha, sha, repoRelDocPath) {
|
|
852
|
+
const nameStatus = git([
|
|
853
|
+
"diff-tree",
|
|
854
|
+
"-r",
|
|
855
|
+
"-M",
|
|
856
|
+
"-z",
|
|
857
|
+
"--name-status",
|
|
858
|
+
"--no-commit-id",
|
|
859
|
+
parentSha,
|
|
860
|
+
sha,
|
|
861
|
+
], repoRoot);
|
|
862
|
+
if (nameStatus === null)
|
|
863
|
+
return { kind: "unknown" };
|
|
864
|
+
// `-z` NUL-terminates every field (status, then path(s)) instead of the
|
|
865
|
+
// default "status TAB path NEWLINE" (or, for a rename/copy row, "status
|
|
866
|
+
// TAB old-path TAB new-path NEWLINE") -- including a trailing NUL after
|
|
867
|
+
// the very last field, which RunGit's `.trim()` does not strip (NUL is
|
|
868
|
+
// not whitespace), so the split below always drops one empty trailing
|
|
869
|
+
// token. A rename/copy row is 3 NUL-terminated tokens (status, old path,
|
|
870
|
+
// new path); every other row is 2 (status, path) -- read positionally,
|
|
871
|
+
// not by re-joining on a separator, since a path can itself legitimately
|
|
872
|
+
// contain a tab or newline once quoting is off.
|
|
873
|
+
const tokens = nameStatus.split("\0").filter((t) => t !== "");
|
|
874
|
+
let i = 0;
|
|
875
|
+
while (i < tokens.length) {
|
|
876
|
+
const status = tokens[i];
|
|
877
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
878
|
+
const oldPath = tokens[i + 1];
|
|
879
|
+
const newPath = tokens[i + 2];
|
|
880
|
+
i += 3;
|
|
881
|
+
if (newPath === repoRelDocPath) {
|
|
882
|
+
return { kind: "renamed", path: oldPath };
|
|
883
|
+
}
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
const p = tokens[i + 1];
|
|
887
|
+
i += 2;
|
|
888
|
+
if (p !== repoRelDocPath)
|
|
889
|
+
continue;
|
|
890
|
+
if (status.startsWith("A"))
|
|
891
|
+
return { kind: "created" };
|
|
892
|
+
return { kind: "same", path: repoRelDocPath };
|
|
893
|
+
}
|
|
894
|
+
return { kind: "same", path: repoRelDocPath };
|
|
895
|
+
}
|
|
119
896
|
//# sourceMappingURL=sources-fresh.js.map
|