session-orchestrator 4.0.0 → 4.1.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +3 -2
- package/.codex-plugin/skills/architecture/SKILL.md +20 -0
- package/.codex-plugin/skills/autopilot/SKILL.md +21 -0
- package/.codex-plugin/skills/autopilot/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/bootstrap/SKILL.md +22 -0
- package/.codex-plugin/skills/bootstrap/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/brainstorm/SKILL.md +22 -0
- package/.codex-plugin/skills/brainstorm/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +17 -0
- package/.codex-plugin/skills/close/SKILL.md +21 -0
- package/.codex-plugin/skills/close/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +24 -0
- package/.codex-plugin/skills/debug/SKILL.md +21 -0
- package/.codex-plugin/skills/debug/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/discovery/SKILL.md +21 -0
- package/.codex-plugin/skills/discovery/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/dispatcher/SKILL.md +21 -0
- package/.codex-plugin/skills/dispatcher/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/docs-orchestrator/SKILL.md +20 -0
- package/.codex-plugin/skills/ecosystem-health/SKILL.md +22 -0
- package/.codex-plugin/skills/eli5/SKILL.md +21 -0
- package/.codex-plugin/skills/eli5/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/eval/SKILL.md +21 -0
- package/.codex-plugin/skills/eval/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/evolve/SKILL.md +21 -0
- package/.codex-plugin/skills/evolve/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/frontmatter-guard/SKILL.md +17 -0
- package/.codex-plugin/skills/gitlab-ops/SKILL.md +22 -0
- package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +17 -0
- package/.codex-plugin/skills/go/SKILL.md +22 -0
- package/.codex-plugin/skills/go/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/grill/SKILL.md +21 -0
- package/.codex-plugin/skills/grill/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/harness-audit/SKILL.md +19 -0
- package/.codex-plugin/skills/harness-audit/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/hook-development/SKILL.md +17 -0
- package/.codex-plugin/skills/mcp-builder/SKILL.md +17 -0
- package/.codex-plugin/skills/memory-cleanup/SKILL.md +21 -0
- package/.codex-plugin/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/mode-selector/SKILL.md +19 -0
- package/.codex-plugin/skills/npm-publish/SKILL.md +18 -0
- package/.codex-plugin/skills/peekaboo-driver/SKILL.md +20 -0
- package/.codex-plugin/skills/persona-panel/SKILL.md +22 -0
- package/.codex-plugin/skills/persona-panel/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/plan/SKILL.md +22 -0
- package/.codex-plugin/skills/plan/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/playwright-driver/SKILL.md +22 -0
- package/.codex-plugin/skills/portfolio/SKILL.md +21 -0
- package/.codex-plugin/skills/portfolio/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/quality-gates/SKILL.md +22 -0
- package/.codex-plugin/skills/reconcile/SKILL.md +21 -0
- package/.codex-plugin/skills/reconcile/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/release/SKILL.md +22 -0
- package/.codex-plugin/skills/release/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/remote-offload/SKILL.md +22 -0
- package/.codex-plugin/skills/repo-audit/SKILL.md +19 -0
- package/.codex-plugin/skills/repo-audit/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/session/SKILL.md +21 -0
- package/.codex-plugin/skills/session/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/session-end/SKILL.md +22 -0
- package/.codex-plugin/skills/session-plan/SKILL.md +22 -0
- package/.codex-plugin/skills/session-start/SKILL.md +22 -0
- package/.codex-plugin/skills/spinout/SKILL.md +21 -0
- package/.codex-plugin/skills/spinout/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/sunset-review/SKILL.md +21 -0
- package/.codex-plugin/skills/sunset-review/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/templates-ack/SKILL.md +21 -0
- package/.codex-plugin/skills/templates-ack/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test/SKILL.md +21 -0
- package/.codex-plugin/skills/test/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-runner/SKILL.md +22 -0
- package/.codex-plugin/skills/tmux-layout/SKILL.md +23 -0
- package/.codex-plugin/skills/using-orchestrator/SKILL.md +19 -0
- package/.codex-plugin/skills/vault-mirror/SKILL.md +17 -0
- package/.codex-plugin/skills/vault-sync/SKILL.md +17 -0
- package/.codex-plugin/skills/wave-executor/SKILL.md +22 -0
- package/.codex-plugin/skills/write-executable-plan/SKILL.md +24 -0
- package/{plugin.json → .cursor-plugin/plugin.json} +5 -2
- package/CHANGELOG.md +213 -1
- package/README.md +70 -58
- package/commands/release.md +4 -4
- package/docs/codex-setup.md +43 -9
- package/docs/components.md +3 -2
- package/docs/instruction-delivery.md +12 -5
- package/docs/migration-v4.md +33 -9
- package/hooks/_lib/hook-import-set.json +4 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +25 -4
- package/package.json +2 -2
- package/scripts/ci/assert-coverage-green.mjs +100 -0
- package/scripts/generate-codex-skills.mjs +246 -0
- package/scripts/generate-hook-import-set.mjs +51 -8
- package/scripts/lib/codex/plugin-contract.mjs +6 -0
- package/scripts/lib/config/host-paths.mjs +20 -4
- package/scripts/lib/events.mjs +3 -3
- package/scripts/lib/gates/gate-full.mjs +7 -3
- package/scripts/lib/owner-config-banner.mjs +7 -9
- package/scripts/lib/owner-yaml.mjs +8 -1
- package/scripts/lib/plugin-update-banner.mjs +10 -2
- package/scripts/lib/project-hygiene.mjs +182 -6
- package/scripts/lib/reconcile/engine.mjs +38 -7
- package/scripts/lib/session-identity/own-session.mjs +24 -13
- package/scripts/lib/session-schema/constants.mjs +38 -11
- package/scripts/lib/session-start-probes.mjs +12 -0
- package/scripts/lib/telemetry/schema.mjs +39 -18
- package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
- package/scripts/lib/validate/check-codex-skills.mjs +191 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +107 -62
- package/scripts/lib/validate/check-skill-links.mjs +37 -7
- package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
- package/scripts/lib/validate/check-unwired-features.mjs +163 -13
- package/scripts/lib/validate/confidential-names.mjs +95 -30
- package/scripts/lib/validate/repo-files.mjs +48 -14
- package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
- package/scripts/release.mjs +141 -29
- package/scripts/site-numbers.mjs +344 -8
- package/scripts/validate-plugin.mjs +3 -0
- package/skills/session-start/SKILL.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +5 -0
- package/skills/vault-sync/SKILL.md +10 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* check-owner-leakage.mjs — Scan tracked files for owner-privacy leakage patterns.
|
|
4
|
+
* Add --include-untracked to also scan new files that Git does not ignore.
|
|
4
5
|
*
|
|
5
6
|
* Implements the #462 audit trail durable CI guard (#471).
|
|
6
7
|
* Canonicalization-before-matching refactor (issue #661): the historical
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* toward over-matching: this is a security guard, a false-positive is cheap, a
|
|
20
21
|
* false-negative ships a leak to the public mirror.
|
|
21
22
|
*
|
|
22
|
-
* Usage: check-owner-leakage.mjs <plugin-root>
|
|
23
|
+
* Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]
|
|
23
24
|
*
|
|
24
25
|
* Forbidden patterns (canonical rules CP1–CP10):
|
|
25
26
|
* CP1 personal home path `/Users/bernhardg…` — matched on the CANONICAL form,
|
|
@@ -128,8 +129,9 @@ const isMain =
|
|
|
128
129
|
|
|
129
130
|
// CLI: single positional arg required (only enforced when run directly).
|
|
130
131
|
const pluginRoot = argv[2];
|
|
132
|
+
const includeUntracked = argv.slice(3).includes('--include-untracked');
|
|
131
133
|
if (isMain && !pluginRoot) {
|
|
132
|
-
console.error('Usage: check-owner-leakage.mjs <plugin-root>');
|
|
134
|
+
console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]');
|
|
133
135
|
process.exit(1);
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -751,7 +753,7 @@ function rawConfidentialNamesKeyState(ownerYamlPath) {
|
|
|
751
753
|
* be called only from runScan() — NOT at module top-level. This module is
|
|
752
754
|
* dual-mode (CLI entry point AND importable library, `isMain` guard); a top-level
|
|
753
755
|
* fs read would fire on every library import (e.g. pseudonym-map.mjs importing
|
|
754
|
-
* isOwnerLeakySegment).
|
|
756
|
+
* isOwnerLeakySegment). inspectConfidentialNames caches per path, so calling this
|
|
755
757
|
* once per scan reads the file at most once.
|
|
756
758
|
*
|
|
757
759
|
* STANDALONE-SAFE: the two host-local helper modules are DYNAMICALLY imported
|
|
@@ -789,9 +791,10 @@ function rawConfidentialNamesKeyState(ownerYamlPath) {
|
|
|
789
791
|
* exists to block. The operator knows their own path; the CLASS of failure is what
|
|
790
792
|
* this line has to convey.
|
|
791
793
|
*
|
|
794
|
+
* @param {{ loadHostPaths?: typeof import('../config/host-paths.mjs').loadHostPaths }} [opts]
|
|
792
795
|
* @returns {Promise<{ patterns: RegExp[], disabledReason?: string, inertWarn?: string }>}
|
|
793
796
|
*/
|
|
794
|
-
async function getConfidentialNamePatterns() {
|
|
797
|
+
export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
795
798
|
let helpers;
|
|
796
799
|
try {
|
|
797
800
|
helpers = {
|
|
@@ -803,7 +806,26 @@ async function getConfidentialNamePatterns() {
|
|
|
803
806
|
};
|
|
804
807
|
} catch (err) {
|
|
805
808
|
if (err?.code === 'ERR_MODULE_NOT_FOUND' && isMissingDirectSibling(err)) {
|
|
806
|
-
// (a) standalone single-file vendoring
|
|
809
|
+
// (a) standalone single-file vendoring. The degrade is documented — but it
|
|
810
|
+
// must not swallow a CP11 that the operator DID configure (GitLab #1262).
|
|
811
|
+
// No helper resolved here, so the only configuration signal still readable
|
|
812
|
+
// is the raw env var: a literal mirror of ENV_KEYS['confidential-names-file']
|
|
813
|
+
// in scripts/lib/config/host-paths.mjs, which cannot be imported on this
|
|
814
|
+
// branch by construction.
|
|
815
|
+
//
|
|
816
|
+
// LIMIT (named, not hidden): an owner.yaml-configured names file is
|
|
817
|
+
// STRUCTURALLY undetectable in a standalone copy — resolveOwnerYamlPath()
|
|
818
|
+
// lives in the very module chain that failed to resolve, so there is no way
|
|
819
|
+
// to learn that owner.yaml even exists, let alone what it configures. Only
|
|
820
|
+
// the env route can fail closed here; the owner.yaml route degrades inert.
|
|
821
|
+
const envNamesPath = process.env.SO_CONFIDENTIAL_NAMES_FILE;
|
|
822
|
+
if (typeof envNamesPath === 'string' && envNamesPath.trim() !== '') {
|
|
823
|
+
return {
|
|
824
|
+
patterns: [],
|
|
825
|
+
disabledReason:
|
|
826
|
+
'CP11 configured via SO_CONFIDENTIAL_NAMES_FILE but the confidential-names helpers are not resolvable (standalone copy) — failing closed',
|
|
827
|
+
};
|
|
828
|
+
}
|
|
807
829
|
return {
|
|
808
830
|
patterns: [],
|
|
809
831
|
inertWarn: 'CP11 inert — confidential-names helpers not resolvable (standalone copy)',
|
|
@@ -814,36 +836,61 @@ async function getConfidentialNamePatterns() {
|
|
|
814
836
|
}
|
|
815
837
|
|
|
816
838
|
try {
|
|
817
|
-
const {
|
|
818
|
-
const {
|
|
819
|
-
const {
|
|
839
|
+
const { resolveHostPath } = helpers.hostPaths;
|
|
840
|
+
const { inspectConfidentialNames } = helpers.confidentialNames;
|
|
841
|
+
const { resolveOwnerYamlPath } = helpers.ownerYaml;
|
|
842
|
+
|
|
843
|
+
// ONE owner.yaml load. Since #1251 loadHostPaths() passes the loader's own
|
|
844
|
+
// health (source / reason / droppedSections) straight through, so the
|
|
845
|
+
// env>owner.yaml>default precedence and the load's diagnosis come from the
|
|
846
|
+
// same read — no second loadOwnerConfig() call to recover what was discarded.
|
|
847
|
+
const hostCtx = (loadHostPaths ?? helpers.hostPaths.loadHostPaths)();
|
|
848
|
+
|
|
849
|
+
// FAIL-CLOSED on an unknowable load (LOW-2). `loadHostPaths` has its own
|
|
850
|
+
// defensive catch that swallows a THROWING owner loader and returns
|
|
851
|
+
// `{ ownerConfig: undefined, env, source: undefined, reason: undefined }`.
|
|
852
|
+
// Without this branch that shape resolves `namesPath` to '' , fires no
|
|
853
|
+
// reason branch, and returns `{ patterns: [] }` — a silent PASS.
|
|
854
|
+
// Only the throw produces all-undefined: `loadOwnerConfig` returns
|
|
855
|
+
// `{ config: getDefaults(), source: 'defaults', errors: [] }` for a
|
|
856
|
+
// genuinely ABSENT owner.yaml (owner-yaml.mjs:587-590) and `source:
|
|
857
|
+
// 'defaults'` with a `reason` for every read/parse failure — so a missing
|
|
858
|
+
// file keeps taking the normal path below. Latent today (the loader
|
|
859
|
+
// returns on every error path), load-bearing if that ever changes.
|
|
860
|
+
if (
|
|
861
|
+
hostCtx.ownerConfig === undefined &&
|
|
862
|
+
hostCtx.source === undefined &&
|
|
863
|
+
hostCtx.reason === undefined
|
|
864
|
+
) {
|
|
865
|
+
return {
|
|
866
|
+
patterns: [],
|
|
867
|
+
disabledReason:
|
|
868
|
+
'CP11 could not determine whether a confidential-names file is configured (owner config loader failed) — failing closed',
|
|
869
|
+
};
|
|
870
|
+
}
|
|
820
871
|
|
|
821
|
-
|
|
822
|
-
// env>owner.yaml>default precedence is unchanged while the load's own health
|
|
823
|
-
// (reason / droppedSections) stays visible here.
|
|
824
|
-
const owner = loadOwnerConfig();
|
|
825
|
-
const namesPath = resolveHostPath('confidential-names-file', '', loadHostPaths({ ownerLoader: () => owner }));
|
|
872
|
+
const namesPath = resolveHostPath('confidential-names-file', '', hostCtx);
|
|
826
873
|
|
|
827
874
|
if (typeof namesPath !== 'string' || namesPath.trim() === '') {
|
|
828
875
|
// Nothing resolves a names file. That is either (b) — genuinely
|
|
829
876
|
// unconfigured — or a state in which the answer is UNKNOWABLE because the
|
|
830
877
|
// owner.yaml that would carry it could not be read. Unknowable is (c).
|
|
831
878
|
if (existsSync(resolveOwnerYamlPath())) {
|
|
832
|
-
if (
|
|
879
|
+
if (hostCtx.reason === 'yaml-parser-missing') {
|
|
833
880
|
return {
|
|
834
881
|
patterns: [],
|
|
835
882
|
disabledReason:
|
|
836
883
|
"owner.yaml exists but 'js-yaml' is not installed, so a configured confidential-names-file cannot be resolved (run 'npm install')",
|
|
837
884
|
};
|
|
838
885
|
}
|
|
839
|
-
if (
|
|
886
|
+
if (hostCtx.reason === 'unparseable') {
|
|
840
887
|
return {
|
|
841
888
|
patterns: [],
|
|
842
889
|
disabledReason:
|
|
843
890
|
'owner.yaml exists but could not be parsed, so a configured confidential-names-file cannot be resolved',
|
|
844
891
|
};
|
|
845
892
|
}
|
|
846
|
-
if (
|
|
893
|
+
if (hostCtx.droppedSections?.some((d) => d.section === 'paths')) {
|
|
847
894
|
// The paths: section was replaced by its default because SOME key in it
|
|
848
895
|
// is invalid — which says nothing yet about whether CP11 was configured.
|
|
849
896
|
// Re-read the RAW file for that one key (the loader does not expose it on
|
|
@@ -875,15 +922,37 @@ async function getConfidentialNamePatterns() {
|
|
|
875
922
|
return { patterns: [] }; // (b) the ~99% default — inactive, silent.
|
|
876
923
|
}
|
|
877
924
|
|
|
878
|
-
// A names file IS configured.
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
925
|
+
// A names file IS configured. Since #1250 the loader REPORTS the class it
|
|
926
|
+
// used to collapse into `null`, so the verdict reads straight off `status` —
|
|
927
|
+
// no second read of the file to re-derive it. Only `[]` ('empty') is an
|
|
928
|
+
// operator choice (inactive, silent); 'missing'/'malformed'/'all-dropped'
|
|
929
|
+
// mean a configured guard cannot run, which is (c) and fails closed. The
|
|
930
|
+
// reasons carry no path (see above). `inspectConfidentialNames` is the
|
|
931
|
+
// discriminated entry point; `loadConfidentialNames` keeps its 4.0.0
|
|
932
|
+
// `string[] | null` shape for external deep-importers and cannot express
|
|
933
|
+
// this distinction.
|
|
934
|
+
const { status, names } = inspectConfidentialNames({ namesPath });
|
|
935
|
+
if (status === 'missing') {
|
|
936
|
+
return { patterns: [], disabledReason: 'a confidential-names-file is configured but does not exist' };
|
|
937
|
+
}
|
|
938
|
+
if (status === 'malformed') {
|
|
939
|
+
return {
|
|
940
|
+
patterns: [],
|
|
941
|
+
disabledReason: 'the configured confidential-names-file is unreadable or not a JSON array of names',
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
if (status === 'all-dropped') {
|
|
945
|
+
// W4 finding F3 (fail-open): the file listed entries and validation rejected
|
|
946
|
+
// every one of them (non-strings, blanks, over-long). That is a CORRUPTED
|
|
947
|
+
// list, not the `[]` opt-out — CP11 would run with zero patterns and report
|
|
948
|
+
// the clean verdict it never earned. Same class as 'malformed'.
|
|
949
|
+
return {
|
|
950
|
+
patterns: [],
|
|
951
|
+
disabledReason:
|
|
952
|
+
'every entry in the configured confidential-names-file was rejected as invalid or oversized',
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
if (status !== 'ok') return { patterns: [] }; // 'empty' — the operator's `[]` opt-out.
|
|
887
956
|
return { patterns: names.map((name) => new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')) };
|
|
888
957
|
} catch (err) {
|
|
889
958
|
// The helpers resolved but something below threw. CP11 cannot run — fail closed.
|
|
@@ -891,40 +960,6 @@ async function getConfidentialNamePatterns() {
|
|
|
891
960
|
}
|
|
892
961
|
}
|
|
893
962
|
|
|
894
|
-
/**
|
|
895
|
-
* Classify a CONFIGURED confidential-names file as usable or not.
|
|
896
|
-
*
|
|
897
|
-
* `loadConfidentialNames()` collapses "file missing / unreadable / malformed /
|
|
898
|
-
* not an array" and "well-formed but empty" into one `null` return, which is
|
|
899
|
-
* exactly the distinction CP11's fail-closed verdict turns on. Rather than widen
|
|
900
|
-
* that module's contract (it has four other consumers of its `null`), this reads
|
|
901
|
-
* the file once more for classification only. It never returns file CONTENT — the
|
|
902
|
-
* reason string carries the error CLASS alone, matching the confidential-names
|
|
903
|
-
* privacy invariant and the no-path rule above.
|
|
904
|
-
*
|
|
905
|
-
* @param {string} namesPath
|
|
906
|
-
* @returns {string|null} a reason string when unusable, null when usable.
|
|
907
|
-
*/
|
|
908
|
-
function classifyNamesFile(namesPath) {
|
|
909
|
-
let raw;
|
|
910
|
-
try {
|
|
911
|
-
if (!existsSync(namesPath)) {
|
|
912
|
-
return 'a confidential-names-file is configured but does not exist';
|
|
913
|
-
}
|
|
914
|
-
raw = readFileSync(namesPath, 'utf8');
|
|
915
|
-
} catch (err) {
|
|
916
|
-
return `the configured confidential-names-file is unreadable (${err?.name ?? 'Error'})`;
|
|
917
|
-
}
|
|
918
|
-
try {
|
|
919
|
-
if (!Array.isArray(JSON.parse(raw))) {
|
|
920
|
-
return 'the configured confidential-names-file is not a JSON array';
|
|
921
|
-
}
|
|
922
|
-
} catch (err) {
|
|
923
|
-
return `the configured confidential-names-file contains malformed JSON (${err?.name ?? 'Error'})`;
|
|
924
|
-
}
|
|
925
|
-
return null;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
963
|
// ---------------------------------------------------------------------------
|
|
929
964
|
// Text-scan extension allowlist (spec A.2)
|
|
930
965
|
// ---------------------------------------------------------------------------
|
|
@@ -955,6 +990,7 @@ function classifyNamesFile(namesPath) {
|
|
|
955
990
|
// adding: all 7 tracked `.jsonl` files pass (1525 -> 1532 scanned, 0 findings).
|
|
956
991
|
const TEXT_EXTS = new Set([
|
|
957
992
|
'.md',
|
|
993
|
+
'.mdx',
|
|
958
994
|
'.mjs',
|
|
959
995
|
'.js',
|
|
960
996
|
'.ts',
|
|
@@ -989,9 +1025,14 @@ function isTextFile(filePath) {
|
|
|
989
1025
|
|
|
990
1026
|
function getTrackedFiles() {
|
|
991
1027
|
try {
|
|
992
|
-
|
|
1028
|
+
// NUL delimiters preserve Unicode, spaces and newlines without Git's quoted
|
|
1029
|
+
// filename encoding. --exclude-standard applies only to untracked entries;
|
|
1030
|
+
// tracked files remain visible even if an ignore rule now matches them.
|
|
1031
|
+
const args = ['ls-files', '-z', '--cached'];
|
|
1032
|
+
if (includeUntracked) args.push('--others', '--exclude-standard');
|
|
1033
|
+
const output = execFileSync('git', args, { cwd: pluginRoot, encoding: 'utf8' });
|
|
993
1034
|
return output
|
|
994
|
-
.split('\
|
|
1035
|
+
.split('\0')
|
|
995
1036
|
.filter(Boolean)
|
|
996
1037
|
.map((f) => join(pluginRoot, f));
|
|
997
1038
|
} catch {
|
|
@@ -1077,6 +1118,10 @@ function isAllowlisted(relPath, line) {
|
|
|
1077
1118
|
'site/index.html',
|
|
1078
1119
|
'site/impressum/index.html',
|
|
1079
1120
|
'site/datenschutz/index.html',
|
|
1121
|
+
// The German landing page (2026-09-07 redesign) carries the same publisher
|
|
1122
|
+
// identity (JSON-LD @id + footer rel="author") as site/index.html. Exact
|
|
1123
|
+
// path, never a site/de/ prefix — site/guide/index.html stays outside.
|
|
1124
|
+
'site/de/index.html',
|
|
1080
1125
|
]);
|
|
1081
1126
|
const inAllowlistedFile = ALLOWLISTED_URL_PATHS.has(norm);
|
|
1082
1127
|
|
|
@@ -1366,7 +1411,7 @@ if (violations.length === 0) {
|
|
|
1366
1411
|
}
|
|
1367
1412
|
|
|
1368
1413
|
console.log('');
|
|
1369
|
-
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
1414
|
+
console.log(`Results: ${passed} passed, ${failed} failed (${scanFiles.length} scanned files)`);
|
|
1370
1415
|
process.exit(failed === 0 ? 0 : 1);
|
|
1371
1416
|
}
|
|
1372
1417
|
|
|
@@ -43,13 +43,36 @@
|
|
|
43
43
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
44
44
|
import { join, dirname, resolve, relative, sep } from 'node:path';
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Surfaces whose markdown is instruction, i.e. read and acted on.
|
|
48
|
+
*
|
|
49
|
+
* `docs/` joined the list in #1258 after the annotate-before-widen step the sibling
|
|
50
|
+
* `check-skill-script-paths.mjs` ran for #1208: a read-only re-scan (`dirs: ['docs']`,
|
|
51
|
+
* 2026-09-07) returned 92 files / 111 relative links / 8 findings — six of them GitLab
|
|
52
|
+
* `-/issues/N` renderer links now carved out by `SKIP_TARGET`, two of them genuinely dangling
|
|
53
|
+
* links to PRDs that had been archived to the private Meta-Vault. Both classes were resolved
|
|
54
|
+
* before the widening, so the widening itself lands at 0 findings.
|
|
55
|
+
*/
|
|
56
|
+
export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', '.claude/rules', 'docs']);
|
|
48
57
|
|
|
49
58
|
/** Path segments that end the walk: vendored or machine-owned trees, never instruction. */
|
|
50
59
|
export const PRUNE_DIRS = new Set(['node_modules', '.git', 'coverage', 'dist', '.pnpm']);
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Targets this checker never resolves against the filesystem.
|
|
63
|
+
*
|
|
64
|
+
* The `-/issues/N` · `-/merge_requests/N` branch is a GitLab RENDERER convention, not a link
|
|
65
|
+
* defect: `../../../-/issues/174` is how a doc nested two levels deep points at the project's
|
|
66
|
+
* issue tracker, and GitLab resolves it correctly in its own Markdown view. It leaves the repo
|
|
67
|
+
* by construction, so a filesystem checker can only ever call it dangling. Measured 2026-09-07:
|
|
68
|
+
* 6 of the 8 `docs/` findings were exactly this shape, all in `docs/owner-config-schema.md`.
|
|
69
|
+
*
|
|
70
|
+
* The branch is anchored at BOTH ends. Left-unanchored it would still be a carve-out, but
|
|
71
|
+
* right-unanchored it swallowed every path that merely PASSES THROUGH such a segment —
|
|
72
|
+
* `docs/-/issues/12/../../secrets.md` is a genuinely dangling link and was SKIPped. The
|
|
73
|
+
* optional trailing `#note_123` is kept because GitLab issue links legitimately carry one.
|
|
74
|
+
*/
|
|
75
|
+
const SKIP_TARGET = /^(https?:|mailto:|#|\/)|(^|\/)-\/(issues|merge_requests)\/\d+(#[\w-]*)?$/i;
|
|
53
76
|
const LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
54
77
|
const INLINE_CODE_RE = /(`+)[^`]*?\1/g;
|
|
55
78
|
|
|
@@ -89,11 +112,15 @@ function fencedLineNumbers(text) {
|
|
|
89
112
|
* these four directories would also be checked — there are none, and one would be a finding worth
|
|
90
113
|
* seeing anyway.
|
|
91
114
|
*
|
|
115
|
+
* @param {string} repoRoot
|
|
116
|
+
* @param {{dirs?: readonly string[]}} [options] `dirs` defaults to {@link SCAN_DIRS}; it exists so
|
|
117
|
+
* an annotate-before-widen dry-run over a candidate surface needs no re-implementation of this
|
|
118
|
+
* predicate (same shape as `scanSkillScriptPaths({ dirs })`).
|
|
92
119
|
* @returns {string[]} repo-relative paths, sorted for stable output
|
|
93
120
|
*/
|
|
94
|
-
export function listMarkdown(repoRoot) {
|
|
121
|
+
export function listMarkdown(repoRoot, { dirs = SCAN_DIRS } = {}) {
|
|
95
122
|
const out = [];
|
|
96
|
-
for (const dir of
|
|
123
|
+
for (const dir of dirs) {
|
|
97
124
|
const abs = join(repoRoot, dir);
|
|
98
125
|
let entries;
|
|
99
126
|
try { entries = readdirSync(abs, { recursive: true, withFileTypes: true }); } catch { continue; }
|
|
@@ -112,12 +139,15 @@ export function listMarkdown(repoRoot) {
|
|
|
112
139
|
}
|
|
113
140
|
|
|
114
141
|
/**
|
|
142
|
+
* @param {string} [repoRoot]
|
|
143
|
+
* @param {{dirs?: readonly string[]}} [options] `dirs` defaults to {@link SCAN_DIRS} — see
|
|
144
|
+
* {@link listMarkdown}.
|
|
115
145
|
* @returns {{ok: boolean, checked: number, files: number, findings: Array<{file: string, line: number, target: string}>}}
|
|
116
146
|
*/
|
|
117
|
-
export function checkSkillLinks(repoRoot = process.cwd()) {
|
|
147
|
+
export function checkSkillLinks(repoRoot = process.cwd(), { dirs = SCAN_DIRS } = {}) {
|
|
118
148
|
const findings = [];
|
|
119
149
|
let checked = 0;
|
|
120
|
-
const files = listMarkdown(repoRoot);
|
|
150
|
+
const files = listMarkdown(repoRoot, { dirs });
|
|
121
151
|
|
|
122
152
|
for (const rel of files) {
|
|
123
153
|
const abs = join(repoRoot, rel);
|
|
@@ -40,6 +40,37 @@
|
|
|
40
40
|
* measures that second population so the gap is visible in the OUTPUT, not
|
|
41
41
|
* only in this comment.
|
|
42
42
|
*
|
|
43
|
+
* ## Wrapper-aware: `fixtureGit` / `fixtureGitSpawn` are git invocations
|
|
44
|
+
*
|
|
45
|
+
* Since 2026-09-06 most fixtures do not spawn `git` themselves — they call
|
|
46
|
+
* `fixtureGit(args, cwd, opts)` / `fixtureGitSpawn(…)` from
|
|
47
|
+
* `tests/_helpers/tmp-fixture.mjs`, which prepend `...NO_BACKGROUND_WRITER` and
|
|
48
|
+
* spawn the binary inside the helper. Two consequences, both measured
|
|
49
|
+
* 2026-09-07 against this tree:
|
|
50
|
+
*
|
|
51
|
+
* - Resolving only the helper's own two `execFileSync`/`spawnSync` lines
|
|
52
|
+
* (i.e. teaching {@link classifyArgv} to skip the allowlisted flag-only
|
|
53
|
+
* spread, see {@link NOOP_PREFIX_SPREADS}) changes NOTHING: `applicable`
|
|
54
|
+
* stays at **17**, because the token after the allowlisted spread is
|
|
55
|
+
* `...args` — an unknown spread the census must still refuse to guess past.
|
|
56
|
+
* - Recognising the two helpers BY NAME, and reading the argv array's first
|
|
57
|
+
* element as the subcommand, lifts `applicable` to **~200** with **0**
|
|
58
|
+
* findings (197 → 198 → 200 across the 2026-09-07 re-measurements as
|
|
59
|
+
* sibling waves added fixtures; re-measure before quoting) — the census
|
|
60
|
+
* then sees the real test population instead of a residue. That population is **244** `fixtureGit`/`fixtureGitSpawn` call
|
|
61
|
+
* sites, measured 2026-09-07 with
|
|
62
|
+
* `rg -o "\bfixtureGit(Spawn)?\s*\(" tests/ -g '*.mjs' | wc -l`
|
|
63
|
+
* (the header claimed 239 until that re-measurement; `applicable` is the
|
|
64
|
+
* smaller number because read-only and unresolved-argv calls are excluded).
|
|
65
|
+
*
|
|
66
|
+
* Wrapper-aware is therefore the mode this file implements. It costs one extra
|
|
67
|
+
* rule: the helper takes its target as the SECOND POSITIONAL, not as a `cwd:`
|
|
68
|
+
* key, and `undefined`, `null` or an ABSENT second positional all mean "no
|
|
69
|
+
* destination was handed to the helper" — see {@link wrapperHasCwd}, which
|
|
70
|
+
* judges the COMMENT-STRIPPED tail so an inline comment cannot stand in for the
|
|
71
|
+
* argument. Judging a wrapper call by the `cwd:` scan alone would have reported
|
|
72
|
+
* nearly every routed fixture as untargeted.
|
|
73
|
+
*
|
|
43
74
|
* Further named gaps:
|
|
44
75
|
* - **A non-literal argv array is never judged.** `execFileSync('git', args, …)`
|
|
45
76
|
* cannot be classified from the call site; counted as `unresolvedArgv`.
|
|
@@ -163,6 +194,16 @@ const TARGET_DECLARING_GLOBALS = Object.freeze(['-C', '--git-dir', '--work-tree'
|
|
|
163
194
|
/** Call shapes that hand an argv ARRAY to a `git` binary. */
|
|
164
195
|
const ARGV_CALL_RE = /\b(?:execFileSync|execFile|spawnSync|spawn)\s*\(\s*['"]git['"]\s*,\s*/g;
|
|
165
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Call shapes that route a `git` argv through this suite's fixture helpers.
|
|
199
|
+
*
|
|
200
|
+
* `fixtureGit(args, cwd, opts)` / `fixtureGitSpawn(args, cwd, opts)` from
|
|
201
|
+
* `tests/_helpers/tmp-fixture.mjs` ARE git invocations — the binary is inside
|
|
202
|
+
* the helper. Recognising them by name is what makes the census reflect the
|
|
203
|
+
* real test population; see § Wrapper-aware in the header.
|
|
204
|
+
*/
|
|
205
|
+
const WRAPPER_CALL_RE = /\b(?:fixtureGit|fixtureGitSpawn)\s*\(/g;
|
|
206
|
+
|
|
166
207
|
/** Call shapes that hand a SHELL STRING opening with `git` to a shell. */
|
|
167
208
|
const SHELL_CALL_RE = /\b(?:execSync|exec)\s*\(\s*(['"`])\s*git\s/g;
|
|
168
209
|
|
|
@@ -226,15 +267,30 @@ function matchBracket(text, open, openChar, closeChar) {
|
|
|
226
267
|
}
|
|
227
268
|
|
|
228
269
|
/**
|
|
229
|
-
* @typedef {{t:'lit', v:string} | {t:'spread'} | {t:'expr'}} ArgvToken
|
|
230
|
-
* `lit` a string literal · `spread` a `...rest` element
|
|
270
|
+
* @typedef {{t:'lit', v:string} | {t:'spread', name:string} | {t:'expr'}} ArgvToken
|
|
271
|
+
* `lit` a string literal · `spread` a `...rest` element, carrying the spread
|
|
272
|
+
* identifier (`''` when it is not a plain identifier) · `expr` any other
|
|
231
273
|
* expression (an identifier, a member access, an interpolated template).
|
|
232
274
|
*
|
|
233
275
|
* The `spread`/`expr` split is load-bearing, not cosmetic: `['init','-q',dir]`
|
|
234
276
|
* carries its target in an `expr` positional, while `['-C',dir,...args]`
|
|
235
277
|
* carries an unknowable tail in a `spread`. Folding both to "opaque" is what
|
|
236
278
|
* produced the first measurement's 11 false positives.
|
|
279
|
+
*
|
|
280
|
+
* The spread's NAME is load-bearing in turn — see {@link NOOP_PREFIX_SPREADS}.
|
|
281
|
+
*/
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Spread identifiers that are known to contain ONLY `git` global flags, so a
|
|
285
|
+
* leading `...NAME` may be skipped exactly as a literal `-c k=v` run is.
|
|
286
|
+
*
|
|
287
|
+
* An explicit allowlist, never a heuristic: any OTHER spread keeps the
|
|
288
|
+
* fail-closed `subcommand: null`, because guessing past an unknown spread is
|
|
289
|
+
* precisely what produced the 11 false positives named in the header.
|
|
290
|
+
*
|
|
291
|
+
* @type {ReadonlySet<string>}
|
|
237
292
|
*/
|
|
293
|
+
const NOOP_PREFIX_SPREADS = new Set(['NO_BACKGROUND_WRITER']);
|
|
238
294
|
|
|
239
295
|
/**
|
|
240
296
|
* Tokenize a literal argv array body into ordered tokens.
|
|
@@ -245,13 +301,13 @@ function matchBracket(text, open, openChar, closeChar) {
|
|
|
245
301
|
export function tokenizeArgv(inner) {
|
|
246
302
|
/** @type {ArgvToken[]} */
|
|
247
303
|
const tokens = [];
|
|
248
|
-
const re = /(['"])((?:\\.|(?!\1)[^\\])*)\1|`([^`$]*)`|(\.\.\.)[\w$.[\]]
|
|
304
|
+
const re = /(['"])((?:\\.|(?!\1)[^\\])*)\1|`([^`$]*)`|(\.\.\.)([\w$.[\]]*)|([A-Za-z_$][\w$.[\]]*|`[^`]*`)/g;
|
|
249
305
|
/** @type {RegExpExecArray|null} */
|
|
250
306
|
let m;
|
|
251
307
|
while ((m = re.exec(inner)) !== null) {
|
|
252
308
|
if (m[2] !== undefined) tokens.push({ t: 'lit', v: m[2] });
|
|
253
309
|
else if (m[3] !== undefined) tokens.push({ t: 'lit', v: m[3] });
|
|
254
|
-
else if (m[4] !== undefined) tokens.push({ t: 'spread' });
|
|
310
|
+
else if (m[4] !== undefined) tokens.push({ t: 'spread', name: m[5] ?? '' });
|
|
255
311
|
else tokens.push({ t: 'expr' });
|
|
256
312
|
}
|
|
257
313
|
return tokens;
|
|
@@ -268,8 +324,13 @@ export function classifyArgv(tokens) {
|
|
|
268
324
|
let index = 0;
|
|
269
325
|
while (index < tokens.length) {
|
|
270
326
|
const token = tokens[index];
|
|
271
|
-
//
|
|
272
|
-
|
|
327
|
+
// An ALLOWLISTED flag-only spread is skipped exactly like a `-c k=v` run.
|
|
328
|
+
if (token.t === 'spread' && NOOP_PREFIX_SPREADS.has(token.name)) {
|
|
329
|
+
index += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// Any other non-literal in leading-flag position makes the subcommand
|
|
333
|
+
// unknowable; never guess past it.
|
|
273
334
|
if (token.t !== 'lit') return { hasArgvTarget, subcommand: null, rest: [] };
|
|
274
335
|
if (!token.v.startsWith('-')) break;
|
|
275
336
|
|
|
@@ -416,6 +477,84 @@ export function insideStringLiteral(body, index) {
|
|
|
416
477
|
return quote !== null;
|
|
417
478
|
}
|
|
418
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Replace every comment in `text` with a single space, leaving string literals
|
|
482
|
+
* untouched.
|
|
483
|
+
*
|
|
484
|
+
* Not cosmetic: {@link wrapperHasCwd} judges the SECOND POSITIONAL textually,
|
|
485
|
+
* so an inline `/* … *\/` between the comma and the argument used to shift the
|
|
486
|
+
* argument out of view and the call read as targeted — a comment that says
|
|
487
|
+
* "no cwd" measured as a cwd. `inCommentLine` cannot serve here: it answers a
|
|
488
|
+
* question about a whole LINE, while this one runs inside a call expression.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} text
|
|
491
|
+
* @returns {string} same length semantics, comments blanked to one space
|
|
492
|
+
*/
|
|
493
|
+
export function stripComments(text) {
|
|
494
|
+
let out = '';
|
|
495
|
+
/** @type {string|null} */
|
|
496
|
+
let quote = null;
|
|
497
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
498
|
+
const ch = text[i];
|
|
499
|
+
if (quote !== null) {
|
|
500
|
+
out += ch;
|
|
501
|
+
if (ch === '\\') {
|
|
502
|
+
out += text[i + 1] ?? '';
|
|
503
|
+
i += 1;
|
|
504
|
+
} else if (ch === quote) quote = null;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
508
|
+
quote = ch;
|
|
509
|
+
out += ch;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
513
|
+
const end = text.indexOf('*/', i + 2);
|
|
514
|
+
i = end === -1 ? text.length : end + 1;
|
|
515
|
+
out += ' ';
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
519
|
+
const end = text.indexOf('\n', i);
|
|
520
|
+
i = end === -1 ? text.length : end - 1;
|
|
521
|
+
out += ' ';
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
out += ch;
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Decide whether a fixture-helper call names a working directory.
|
|
531
|
+
*
|
|
532
|
+
* `fixtureGit(args, cwd, opts)` takes the target as its SECOND POSITIONAL, not
|
|
533
|
+
* as a `cwd:` key — so the textual `cwd:` scan that judges a raw `execFileSync`
|
|
534
|
+
* would report every one of these as untargeted.
|
|
535
|
+
*
|
|
536
|
+
* Three shapes are explicitly NOT a target, because none of them hands the
|
|
537
|
+
* helper a destination: a literal `undefined` (the documented "the argv names
|
|
538
|
+
* the target" form), a literal `null` (same at runtime — `spawnSync` inherits
|
|
539
|
+
* the ambient cwd for both), and an ABSENT second positional. Judging is done
|
|
540
|
+
* on the COMMENT-STRIPPED tail: `fixtureGit([…], /* no cwd *\/ undefined)` read
|
|
541
|
+
* as targeted before 2026-09-07 — the comment, not the argument, was what the
|
|
542
|
+
* second-positional scan saw.
|
|
543
|
+
*
|
|
544
|
+
* @param {string} tail call arguments AFTER the argv array's `]`, bounded by
|
|
545
|
+
* the call's closing paren
|
|
546
|
+
* @returns {boolean}
|
|
547
|
+
*/
|
|
548
|
+
export function wrapperHasCwd(tail) {
|
|
549
|
+
const clean = stripComments(tail);
|
|
550
|
+
const m = /^\s*,\s*/.exec(clean);
|
|
551
|
+
if (m) {
|
|
552
|
+
const second = clean.slice(m[0].length);
|
|
553
|
+
if (second !== '' && !/^(?:undefined|null)\b/.test(second) && !/^,/.test(second)) return true;
|
|
554
|
+
}
|
|
555
|
+
return CWD_OPTION_RE.test(clean);
|
|
556
|
+
}
|
|
557
|
+
|
|
419
558
|
/**
|
|
420
559
|
* Scan one file for `git` invocations.
|
|
421
560
|
*
|
|
@@ -428,7 +567,7 @@ export function scanFile(relative, body, tally) {
|
|
|
428
567
|
/** @type {Array<{kind: string, file: string, line: number, form: string, command: string, message: string}>} */
|
|
429
568
|
const findings = [];
|
|
430
569
|
|
|
431
|
-
/** @type {Array<{index: number, form: 'argv'|'shell', tokens: Array<string|null>, tail: string}>} */
|
|
570
|
+
/** @type {Array<{index: number, form: 'argv'|'shell'|'wrapper', tokens: Array<string|null>, tail: string}>} */
|
|
432
571
|
const calls = [];
|
|
433
572
|
|
|
434
573
|
ARGV_CALL_RE.lastIndex = 0;
|
|
@@ -454,6 +593,35 @@ export function scanFile(relative, body, tally) {
|
|
|
454
593
|
});
|
|
455
594
|
}
|
|
456
595
|
|
|
596
|
+
WRAPPER_CALL_RE.lastIndex = 0;
|
|
597
|
+
while ((m = WRAPPER_CALL_RE.exec(body)) !== null) {
|
|
598
|
+
const openParen = m.index + m[0].length - 1;
|
|
599
|
+
const callEnd = matchBracket(body, openParen, '(', ')');
|
|
600
|
+
if (callEnd === -1) {
|
|
601
|
+
tally.unresolvedArgv += 1;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const argsText = body.slice(openParen + 1, callEnd);
|
|
605
|
+
const arrayStart = argsText.search(/\S/);
|
|
606
|
+
if (arrayStart === -1 || argsText[arrayStart] !== '[') {
|
|
607
|
+
// `fixtureGit(args, dir)` — a variable argv, unjudgeable like its
|
|
608
|
+
// `execFileSync` counterpart.
|
|
609
|
+
tally.unresolvedArgv += 1;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const close = matchBracket(argsText, arrayStart, '[', ']');
|
|
613
|
+
if (close === -1) {
|
|
614
|
+
tally.unresolvedArgv += 1;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
calls.push({
|
|
618
|
+
index: m.index,
|
|
619
|
+
form: 'wrapper',
|
|
620
|
+
tokens: tokenizeArgv(argsText.slice(arrayStart + 1, close)),
|
|
621
|
+
tail: argsText.slice(close + 1),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
457
625
|
SHELL_CALL_RE.lastIndex = 0;
|
|
458
626
|
while ((m = SHELL_CALL_RE.exec(body)) !== null) {
|
|
459
627
|
const quote = m[1];
|
|
@@ -487,11 +655,20 @@ export function scanFile(relative, body, tally) {
|
|
|
487
655
|
}
|
|
488
656
|
tally.applicable += 1;
|
|
489
657
|
|
|
490
|
-
// The options object: everything up to the end of the call expression.
|
|
658
|
+
// The options object: everything up to the end of the call expression. For
|
|
659
|
+
// a wrapper call `call.tail` is ALREADY bounded by the call's own closing
|
|
660
|
+
// paren, and its second positional — not a `cwd:` key — carries the target.
|
|
661
|
+
const isWrapper = call.form === 'wrapper';
|
|
491
662
|
const callEnd = matchBracket(call.tail, call.tail.indexOf('('), '(', ')');
|
|
492
|
-
const optionsText =
|
|
493
|
-
|
|
494
|
-
|
|
663
|
+
const optionsText = isWrapper
|
|
664
|
+
? call.tail
|
|
665
|
+
: callEnd === -1
|
|
666
|
+
? call.tail.slice(0, 400)
|
|
667
|
+
: call.tail.slice(0, callEnd);
|
|
668
|
+
const opaqueOptions = !isWrapper && OPAQUE_OPTIONS_RE.test(call.tail);
|
|
669
|
+
const hasCwd = isWrapper
|
|
670
|
+
? wrapperHasCwd(call.tail)
|
|
671
|
+
: CWD_OPTION_RE.test(optionsText);
|
|
495
672
|
const hasEnvTarget = GIT_ENV_TARGET_RE.test(optionsText);
|
|
496
673
|
const hasTarget =
|
|
497
674
|
hasArgvTarget || hasCwd || hasEnvTarget || hasSubcommandTarget(subcommand, rest);
|
|
@@ -582,7 +759,10 @@ export function inspectTestGitConfigTarget(pluginRoot) {
|
|
|
582
759
|
});
|
|
583
760
|
return result;
|
|
584
761
|
}
|
|
585
|
-
|
|
762
|
+
// Cheap prefilter. The second alternative is load-bearing since the census
|
|
763
|
+
// became wrapper-aware: a file that only calls `fixtureGit([...])` never
|
|
764
|
+
// writes the quoted binary name and would otherwise be skipped unseen.
|
|
765
|
+
if (!/['"`]\s*git[\s'"`]/.test(body) && !/\bfixtureGit(?:Spawn)?\s*\(/.test(body)) continue;
|
|
586
766
|
findings.push(...scanFile(relative, body, tally));
|
|
587
767
|
}
|
|
588
768
|
|